mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 07:14:56 +00:00
Refactor role validation to ensure all role_ids are active and update role badge logic to prioritize active roles
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ import { permissions } from "../src/lib/allPerms.ts";
|
||||
*
|
||||
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
|
||||
*
|
||||
* admin → all 25 permissions
|
||||
* admin → all permissions
|
||||
* editor → all except api_keys.delete (AdminCan-only)
|
||||
* member → all .read permissions only
|
||||
*/
|
||||
|
||||
@@ -156,12 +156,15 @@ export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
// Validate all role_ids exist
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(data.email);
|
||||
@@ -244,12 +247,15 @@ export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUp
|
||||
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
|
||||
throw new Error("Owner must retain the admin role");
|
||||
}
|
||||
// Validate all role_ids exist
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
return await db.updateUserRoles(forUser.id, data.role_ids);
|
||||
} else if (data.updateType == "is_active") {
|
||||
@@ -304,12 +310,15 @@ export const SendInvitationEmail = async (email: string, role_ids: string[], nam
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
// Validate all role_ids exist
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
@@ -637,6 +646,9 @@ export const AddUserToRole = async (roleId: string, userId: number) => {
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
// Check if user already in role
|
||||
const users = await db.getUsersByRoleId(roleId);
|
||||
if (users.some((u) => u.id === userId)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RolePermissionRecord,
|
||||
UserRoleRecord,
|
||||
} from "../../types/db.js";
|
||||
import { GetDbType } from "../../tool.js";
|
||||
|
||||
/**
|
||||
* Repository for users, API keys operations
|
||||
@@ -80,18 +81,26 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
async insertUser(data: UserRecordInsert): Promise<number[]> {
|
||||
const result = await this.knex("users")
|
||||
.insert({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
password_hash: data.password_hash,
|
||||
is_owner: data.is_owner || "NO",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
})
|
||||
.returning("id");
|
||||
// SQLite returns [number], PG/MySQL return [{id: number}]
|
||||
const userId = typeof result[0] === "object" ? (result[0] as { id: number }).id : (result[0] as number);
|
||||
const dbType = GetDbType();
|
||||
|
||||
const insertData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
password_hash: data.password_hash,
|
||||
is_owner: data.is_owner || "NO",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
};
|
||||
|
||||
let userId: number;
|
||||
if (dbType === "postgresql") {
|
||||
const [row] = await this.knex("users").insert(insertData).returning("id");
|
||||
userId = typeof row === "object" ? (row as { id: number }).id : (row as number);
|
||||
} else {
|
||||
const result = await this.knex("users").insert(insertData);
|
||||
userId = result[0];
|
||||
}
|
||||
|
||||
if (data.role_ids && data.role_ids.length > 0) {
|
||||
const roleInserts = data.role_ids.map((roleId) => ({
|
||||
users_id: userId,
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
<Table.Cell class="pr-4 text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!page.data.userPermissions?.includes("api_keys.write")}
|
||||
disabled={!page.data.userPermissions?.includes("api_keys.delete")}
|
||||
size="sm"
|
||||
onclick={() => openDeleteDialog(apiKey)}
|
||||
>
|
||||
|
||||
@@ -313,18 +313,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Role badge variant
|
||||
function getRoleBadgeVariant(role: string): "default" | "secondary" | "outline" {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "default";
|
||||
case "editor":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
// Role badge variant by precedence: admin > editor > others
|
||||
function getRoleBadgeVariant(roleIds: string[]): "default" | "secondary" | "outline" {
|
||||
if (roleIds.includes("admin")) return "default";
|
||||
if (roleIds.includes("editor")) return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
let activeRoles = $derived(roles.filter((r) => r.status === "ACTIVE"));
|
||||
|
||||
// Fetch roles
|
||||
async function fetchRoles() {
|
||||
try {
|
||||
@@ -449,7 +446,7 @@
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={getRoleBadgeVariant(user.role_ids[0] || "member")} class="uppercase">
|
||||
<Badge variant={getRoleBadgeVariant(user.role_ids)} class="uppercase">
|
||||
{user.role_ids.join(", ")}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
@@ -543,7 +540,7 @@
|
||||
<div class="space-y-2">
|
||||
<Label>Roles</Label>
|
||||
<div class="space-y-2">
|
||||
{#each roles as role (role.id)}
|
||||
{#each activeRoles as role (role.id)}
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={newUser.role_ids.includes(role.id)}
|
||||
@@ -554,7 +551,7 @@
|
||||
<span class="text-sm uppercase">{role.role_name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if roles.length === 0}
|
||||
{#if activeRoles.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No roles available</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -640,7 +637,7 @@
|
||||
Change the roles of the user. The user will have different permissions based on assigned roles.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each roles as role (role.id)}
|
||||
{#each activeRoles as role (role.id)}
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={toEditUser.role_ids.includes(role.id)}
|
||||
@@ -652,7 +649,7 @@
|
||||
<span class="text-sm uppercase">{role.role_name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if roles.length === 0}
|
||||
{#if activeRoles.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No roles available</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import KeyIcon from "@lucide/svelte/icons/key";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||
import TrashIcon from "@lucide/svelte/icons/trash-2";
|
||||
import EyeIcon from "@lucide/svelte/icons/eye";
|
||||
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
|
||||
import SaveIcon from "@lucide/svelte/icons/save";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { onMount } from "svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
// Types
|
||||
interface VaultSecret {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let secrets = $state<VaultSecret[]>([]);
|
||||
let visibleSecrets = new SvelteSet<number>();
|
||||
|
||||
// Form state
|
||||
let isEditing = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let formName = $state("");
|
||||
let formValue = $state("");
|
||||
|
||||
// Delete confirmation state
|
||||
let deleteDialogOpen = $state(false);
|
||||
let secretToDelete = $state<VaultSecret | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// API functions
|
||||
async function loadSecrets() {
|
||||
try {
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "getVaultSecrets" })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else if (Array.isArray(result)) {
|
||||
secrets = result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading secrets:", error);
|
||||
toast.error("Failed to load secrets");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSecret() {
|
||||
if (!formName.trim()) {
|
||||
toast.error("Secret name is required");
|
||||
return;
|
||||
}
|
||||
if (!formValue.trim()) {
|
||||
toast.error("Secret value is required");
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const action = editingId ? "updateVaultSecret" : "createVaultSecret";
|
||||
const payload: Record<string, unknown> = {
|
||||
action,
|
||||
data: {
|
||||
secret_name: formName.trim(),
|
||||
secret_value: formValue
|
||||
}
|
||||
};
|
||||
if (editingId) {
|
||||
payload.data = { ...(payload.data as object), id: editingId };
|
||||
}
|
||||
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(editingId ? "Secret updated successfully" : "Secret created successfully");
|
||||
resetForm();
|
||||
await loadSecrets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving secret:", error);
|
||||
toast.error("Failed to save secret");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!secretToDelete) return;
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "deleteVaultSecret",
|
||||
data: { id: secretToDelete.id }
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success("Secret deleted successfully");
|
||||
await loadSecrets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting secret:", error);
|
||||
toast.error("Failed to delete secret");
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
deleteDialogOpen = false;
|
||||
secretToDelete = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(secret: VaultSecret) {
|
||||
editingId = secret.id;
|
||||
formName = secret.secret_name;
|
||||
formValue = secret.secret_value;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formValue = "";
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formValue = "";
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function openDeleteDialog(secret: VaultSecret) {
|
||||
secretToDelete = secret;
|
||||
deleteDialogOpen = true;
|
||||
}
|
||||
|
||||
function toggleSecretVisibility(id: number) {
|
||||
if (visibleSecrets.has(id)) {
|
||||
visibleSecrets.delete(id);
|
||||
} else {
|
||||
visibleSecrets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function maskValue(value: string): string {
|
||||
return "*".repeat(Math.min(value.length, 20));
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMount(async () => {
|
||||
await loadSecrets();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto space-y-6 py-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<KeyIcon class="text-muted-foreground size-6" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Vault</h1>
|
||||
<p class="text-muted-foreground text-sm">Securely store and manage your secrets</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !isEditing}
|
||||
<Button onclick={startCreate}>
|
||||
<PlusIcon class="size-4" />
|
||||
Add Secret
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Form Card -->
|
||||
{#if isEditing}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{editingId ? "Edit Secret" : "Add New Secret"}</Card.Title>
|
||||
<Card.Description>
|
||||
{editingId ? "Update the secret details below" : "Enter the secret name and value below"}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="secret-name">Secret Name</Label>
|
||||
<Input id="secret-name" placeholder="e.g., API_KEY" bind:value={formName} disabled={saving} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="secret-value">Secret Value</Label>
|
||||
<Textarea
|
||||
id="secret-value"
|
||||
placeholder="Enter secret value..."
|
||||
bind:value={formValue}
|
||||
disabled={saving}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={resetForm} disabled={saving}>
|
||||
<XIcon class="size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={saveSecret} disabled={saving}>
|
||||
{#if saving}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<SaveIcon class="size-4" />
|
||||
{/if}
|
||||
{editingId ? "Update Secret" : "Save Secret"}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Secrets List -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon class="text-muted-foreground size-5" />
|
||||
<div>
|
||||
<Card.Title>Stored Secrets</Card.Title>
|
||||
<Card.Description>All secrets are encrypted using KENER_SECRET_KEY</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if secrets.length === 0}
|
||||
<div class="text-muted-foreground py-8 text-center">
|
||||
No secrets stored yet. Click "Add Secret" to create your first secret.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each secrets as secret (secret.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon class="text-muted-foreground size-4" />
|
||||
<span class="font-medium">{secret.secret_name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
{visibleSecrets.has(secret.id) ? secret.secret_value : maskValue(secret.secret_value)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
onclick={() => toggleSecretVisibility(secret.id)}
|
||||
>
|
||||
{#if visibleSecrets.has(secret.id)}
|
||||
<EyeOffIcon class="size-4" />
|
||||
{:else}
|
||||
<EyeIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => startEdit(secret)} disabled={isEditing}>
|
||||
<PencilIcon class="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={() => openDeleteDialog(secret)} disabled={isEditing}>
|
||||
<TrashIcon class="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Secret</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete the secret "{secretToDelete?.secret_name}"? This action cannot be undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel disabled={isDeleting}>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{#if isDeleting}
|
||||
<Spinner class="size-4" />
|
||||
{/if}
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
Reference in New Issue
Block a user