Thomas Zhang 2 місяців тому
батько
коміт
b0b63f82e7

+ 3 - 0
src/lib/components/resources/secret/index.ts

@@ -0,0 +1,3 @@
+import SecretPanel from './panel.svelte';
+
+export { SecretPanel };

+ 202 - 0
src/lib/components/resources/secret/panel.svelte

@@ -0,0 +1,202 @@
+<script lang="ts">
+	import {
+		ChevronDown,
+		ChevronUp,
+		Copy,
+		Eye,
+		EyeOff,
+		Pencil,
+		Plus,
+		Save,
+		StickyNote,
+		Trash,
+		X,
+	} from '@lucide/svelte';
+	import type { ClassValue } from 'svelte/elements';
+	import { slide } from 'svelte/transition';
+
+	import { CopyButton } from '$lib/components/common/copy';
+	import Empty from '$lib/components/common/empty/empty.svelte';
+	import { Button } from '$lib/components/controls/button';
+	import { Input } from '$lib/components/controls/input';
+	import * as Table from '$lib/components/controls/table';
+	import type { Secret, SecretItem } from '$lib/types/entities';
+
+	let {
+		class: className = '',
+		secret,
+		onsave,
+		onrename,
+		ondelete,
+	}: {
+		class?: ClassValue;
+		secret: Secret;
+		onsave?: (secret: Secret) => void;
+		onrename?: () => void;
+		ondelete?: () => void;
+	} = $props();
+	let shown = $state(false);
+	let valueVisible = $state(false);
+	let editingIdx = $state<number | undefined>(undefined);
+	let editingItem = $state<SecretItem>({ key: '', value: '' });
+	let addingItem = $state(false);
+	let secretItems = $derived<SecretItem[]>(secret.items);
+
+	const onAddItem = () => {
+		if (editingIdx !== undefined) return;
+		secretItems = [...secretItems, { key: '', value: '' }];
+		editingItem = { key: '', value: '' };
+		editingIdx = secretItems.length - 1;
+		addingItem = true;
+		shown = true;
+	};
+
+	const onToggleVisibility = () => {
+		valueVisible = !valueVisible;
+		if (valueVisible) {
+			shown = true;
+		}
+	};
+
+	const onCopy = (text: string) => {
+		navigator.clipboard.writeText(text);
+	};
+
+	const onEdit = (idx: number) => {
+		editingIdx = idx;
+		editingItem = { ...secretItems[idx] };
+	};
+
+	const canSave = () => {
+		if (editingIdx === undefined || !editingItem) return false;
+		if (!editingItem.key.trim()) return false;
+		if (!editingItem.value.trim()) return false;
+		return true;
+	};
+
+	const onSaveItem = () => {
+		if (editingIdx === undefined || !editingItem) return;
+		secretItems[editingIdx] = editingItem;
+		editingIdx = undefined;
+		addingItem = false;
+		onsave?.({ ...secret, items: secretItems });
+	};
+
+	const onDeleteItem = (idx: number) => {
+		if (editingIdx === idx) {
+			editingIdx = undefined;
+		}
+		secretItems = secretItems.filter((_, i) => i !== idx);
+		if (!addingItem) {
+			onsave?.({ ...secret, items: secretItems });
+		}
+		addingItem = false;
+	};
+</script>
+
+<div class={['overflow-hidden rounded-md border border-white/10', className]}>
+	<div class="bg-white/5">
+		<div class="flex items-center justify-between p-2">
+			<div class="p-2 font-thin">{secret.name} ({secret.items.length} Entries)</div>
+			<div class="flex items-center gap-1">
+				<Button
+					disabled={editingIdx !== undefined}
+					size="icon-sm"
+					title="View"
+					variant="ghost"
+					onclick={() => onAddItem()}
+				>
+					<Plus />
+				</Button>
+				<Button size="icon-sm" title="View" variant="ghost" onclick={() => onToggleVisibility()}>
+					{#if valueVisible}
+						<EyeOff />
+					{:else}
+						<Eye />
+					{/if}
+				</Button>
+				<Button size="icon-xs" title="Delete" variant="ghost" onclick={() => ondelete?.()}>
+					<Trash />
+				</Button>
+				<Button size="icon-xs" title="Rename" variant="ghost" onclick={() => onrename?.()}>
+					<Pencil />
+				</Button>
+				<Button
+					disabled={editingIdx !== undefined}
+					size="icon-sm"
+					variant="ghost"
+					onclick={() => (shown = !shown)}
+				>
+					{#if shown}
+						<ChevronUp size={14} />
+					{:else}
+						<ChevronDown size={14} />
+					{/if}
+				</Button>
+			</div>
+		</div>
+	</div>
+	{#if shown}
+		<div class="overflow-hidden" transition:slide={{ duration: 200 }}>
+			<Table.Root>
+				<Table.Body>
+					{#each secretItems as entry, idx (idx)}
+						<Table.Row>
+							<Table.Cell class="inline-flex h-12 w-48 items-center gap-1 truncate">
+								{#if editingIdx === idx}
+									<Input placeholder="Key" type="text" bind:value={editingItem.key} />
+								{:else}
+									<CopyButton content={entry.key} />
+									{entry.key}
+								{/if}
+							</Table.Cell>
+							<Table.Cell class="inline-flex h-12 items-center gap-1">
+								{#if editingIdx === idx}
+									<Input
+										placeholder="Value"
+										type={valueVisible ? 'text' : 'password'}
+										bind:value={editingItem.value}
+									/>
+								{:else}
+									<CopyButton content={entry.value} />
+									{valueVisible ? entry.value : '••••••••'}
+								{/if}
+							</Table.Cell>
+							<Table.Cell class="text-end select-none">
+								{#if editingIdx !== idx}
+									<Button size="icon-xs" title="Edit" variant="ghost" onclick={() => onEdit(idx)}>
+										<Pencil />
+									</Button>
+								{:else}
+									<Button
+										disabled={!canSave()}
+										size="icon-xs"
+										title="Save"
+										variant="ghost"
+										onclick={() => onSaveItem()}
+									>
+										<Save />
+									</Button>
+								{/if}
+								<Button
+									size="icon-xs"
+									title="Delete"
+									variant="ghost"
+									onclick={() => onDeleteItem(idx)}
+								>
+									<X />
+								</Button>
+							</Table.Cell>
+						</Table.Row>
+					{:else}
+						<Empty icon={StickyNote}>
+							{#snippet caption()}
+								<div>No items</div>
+							{/snippet}
+						</Empty>
+					{/each}
+				</Table.Body>
+			</Table.Root>
+		</div>
+	{/if}
+</div>

+ 13 - 0
src/routes/resources/secrets/+page.server.ts

@@ -0,0 +1,13 @@
+import * as secretsApi from '$lib/api/secrets';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, url, locals }) => {
+	const { workspaceId } = locals;
+	if (!workspaceId) return { secrets: [] };
+	const secrets = await secretsApi.list(workspaceId, fetch);
+	const selectedSecretId = url.searchParams.get('secret');
+	return { secrets, selectedSecretId };
+};
+
+export { load };

+ 121 - 0
src/routes/resources/secrets/+page.svelte

@@ -0,0 +1,121 @@
+<script lang="ts">
+	import { Key, Plus } from '@lucide/svelte';
+
+	import { resolve } from '$app/paths';
+
+	import { ConfirmDialog } from '$lib/components/common/confirmation';
+	import { Button, buttonVariants } from '$lib/components/controls/button';
+	import * as Dialog from '$lib/components/controls/dialog';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import { SecretPanel } from '$lib/components/resources/secret';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { secretsStore } from '$lib/stores/secrets.svelte';
+	import type { Secret } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+	let secrets = $derived<Secret[]>(secretsStore.list);
+	let editingDialogShown = $state(false);
+	let editingSecret: Secret = $state({
+		id: '',
+		name: '',
+		items: [],
+	});
+	let deletionConfirmationShown = $state(false);
+
+	$effect.root(() => {
+		secretsStore.hydrate(data.secrets);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Resources',
+			pages: [{ name: 'Secrets', href: resolve('/resources/secrets') }],
+		});
+	});
+
+	const onSaveSecret = async (secret: Secret) => {
+		if (secret.id) {
+			await secretsStore.update(secret);
+		} else {
+			await secretsStore.create(secret);
+		}
+		editingDialogShown = false;
+	};
+
+	const onDeleteSecret = async () => {
+		await secretsStore.remove(editingSecret.id);
+		deletionConfirmationShown = false;
+	};
+</script>
+
+<svelte:head>
+	<title>Secrets - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl p-6">
+	<div class="mb-4 flex items-center justify-between">
+		<div class="flex items-center gap-2 text-xl font-semibold text-white/90">
+			<Key opacity={0.5} size={18} strokeWidth={4} />
+			Secrets
+		</div>
+		<Button size="sm" onclick={() => (editingDialogShown = true)}>
+			<Plus size={14} strokeWidth={2} />
+			New
+		</Button>
+	</div>
+	{#each secrets as secret (secret.id)}
+		<SecretPanel
+			class="mb-2"
+			{secret}
+			onsave={(secret) => onSaveSecret(secret)}
+			onrename={() => {
+				editingSecret = $state.snapshot(secret);
+				editingDialogShown = true;
+			}}
+			ondelete={() => {
+				editingSecret = $state.snapshot(secret);
+				deletionConfirmationShown = true;
+			}}
+		/>
+	{/each}
+</div>
+
+<Dialog.Root bind:open={editingDialogShown}>
+	<Dialog.Content>
+		<Dialog.Header>
+			<Dialog.Title>{editingSecret.id ? 'Edit Secret' : 'New Secret'}</Dialog.Title>
+		</Dialog.Header>
+		<Field.Set>
+			<Field.Field>
+				<Field.Label>Name</Field.Label>
+				<Input placeholder="Enter secret name" required bind:value={editingSecret.name} />
+			</Field.Field>
+		</Field.Set>
+		<Dialog.Footer>
+			<Dialog.Close class={buttonVariants({ variant: 'outline' })} type="button">
+				Cancel
+			</Dialog.Close>
+			<Button type="submit" onclick={() => onSaveSecret(editingSecret)}>
+				{editingSecret.id ? 'Save' : 'Create'}
+			</Button>
+		</Dialog.Footer>
+	</Dialog.Content>
+</Dialog.Root>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting..."
+	title="Delete secret?"
+	variant="destructive"
+	onconfirm={() => onDeleteSecret()}
+	bind:open={deletionConfirmationShown}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{editingSecret.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>