Refactor role validation to ensure all role_ids are active and update role badge logic to prioritize active roles

This commit is contained in:
Raj Nandan Sharma
2026-03-31 20:11:11 +05:30
parent 2aef97c1ed
commit 63e5ec2886
6 changed files with 50 additions and 371 deletions
+1 -1
View File
@@ -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
*/
+15 -3
View File
@@ -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)) {
+21 -12
View File
@@ -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>