Monorepo for Tangled tangled.org
1

Configure Feed

Select the types of activity you want to include in your feed.

web/components: add settings/keys page

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
dawn
date (Jul 23, 2026, 6:48 PM +0300) commit 3a1338e4 parent 0d4afa77 change-id znlpzlwt
+215
+180
web/src/lib/components/settings/tabs/KeysTab.svelte
··· 1 + <script lang="ts"> 2 + import { getAuth } from "$lib/auth.svelte"; 3 + import { createBobbinClient } from "$lib/api/client"; 4 + import { 5 + listPubKeys, 6 + createPubKey, 7 + deletePubKey, 8 + type ListedRecord, 9 + type PublicKeyRecord 10 + } from "$lib/api/settings"; 11 + import Section from "$lib/components/ui/Section.svelte"; 12 + import Button from "$lib/components/ui/Button.svelte"; 13 + import Spinner from "$lib/components/ui/Spinner.svelte"; 14 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 15 + import KeyCard from "../KeyCard.svelte"; 16 + import AddKeyForm from "../AddKeyForm.svelte"; 17 + import Plus from "$icon/plus"; 18 + 19 + const auth = getAuth(); 20 + const user = $derived(auth.currentUser); 21 + 22 + let keys = $state<ListedRecord<PublicKeyRecord>[]>([]); 23 + let fingerprints = $state<Record<string, string>>({}); 24 + let loading = $state(true); 25 + let loadError = $state<string | null>(null); 26 + 27 + let popover = $state<HTMLElement>(); 28 + let name = $state(""); 29 + let key = $state(""); 30 + let submitting = $state(false); 31 + let formError = $state<string | null>(null); 32 + let deleting = $state<string | null>(null); 33 + 34 + // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. 35 + const sshFingerprint = async (pubkey: string): Promise<string | null> => { 36 + const parts = pubkey.trim().split(/\s+/); 37 + const blob = parts.length >= 2 ? parts[1] : parts[0]; 38 + try { 39 + const bytes = Uint8Array.from(atob(blob), (c) => c.charCodeAt(0)); 40 + const digest = await crypto.subtle.digest("SHA-256", bytes); 41 + const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/=+$/, ""); 42 + return `SHA256:${b64}`; 43 + } catch { 44 + return null; 45 + } 46 + }; 47 + 48 + const load = async () => { 49 + const did = user?.did; 50 + if (!did) return; 51 + loading = true; 52 + loadError = null; 53 + try { 54 + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 55 + keys = await listPubKeys(ctx, did); 56 + const entries = await Promise.all( 57 + keys.map(async (k) => [k.rkey, await sshFingerprint(k.value.key)] as const) 58 + ); 59 + fingerprints = Object.fromEntries( 60 + entries.filter(([, fp]) => fp !== null) as [string, string][] 61 + ); 62 + } catch (err) { 63 + loadError = err instanceof Error ? err.message : "Failed to load keys"; 64 + } finally { 65 + loading = false; 66 + } 67 + }; 68 + 69 + $effect(() => { 70 + if (user?.did) void load(); 71 + }); 72 + 73 + const resetForm = () => { 74 + name = ""; 75 + key = ""; 76 + formError = null; 77 + }; 78 + 79 + const add = async () => { 80 + if (!auth.agent) return; 81 + submitting = true; 82 + formError = null; 83 + try { 84 + const trimmedName = name.trim(); 85 + const trimmedKey = key.trim(); 86 + const rkey = await createPubKey(auth.agent, trimmedName, trimmedKey); 87 + const createdAt = new Date().toISOString(); 88 + // prepend to local state (newest first) instead of refetching everything. 89 + keys = [ 90 + { 91 + uri: `at://${auth.agent.sub}/sh.tangled.publicKey/${rkey}`, 92 + rkey, 93 + value: { $type: "sh.tangled.publicKey", name: trimmedName, key: trimmedKey, createdAt } 94 + }, 95 + ...keys 96 + ]; 97 + const fp = await sshFingerprint(trimmedKey); 98 + if (fp) fingerprints = { ...fingerprints, [rkey]: fp }; 99 + resetForm(); 100 + popover?.hidePopover(); 101 + } catch (err) { 102 + formError = err instanceof Error ? err.message : "Failed to add key"; 103 + } finally { 104 + submitting = false; 105 + } 106 + }; 107 + 108 + const remove = async (k: ListedRecord<PublicKeyRecord>) => { 109 + if (!auth.agent) return; 110 + if (!confirm(`Are you sure you want to delete the key ${k.value.name}?`)) return; 111 + deleting = k.rkey; 112 + try { 113 + await deletePubKey(auth.agent, k.rkey); 114 + // drop just this key from local state instead of refetching everything. 115 + keys = keys.filter((x) => x.rkey !== k.rkey); 116 + } catch (err) { 117 + loadError = err instanceof Error ? err.message : "Failed to delete key"; 118 + } finally { 119 + deleting = null; 120 + } 121 + }; 122 + </script> 123 + 124 + <div class="flex flex-col gap-4"> 125 + <div> 126 + <div class="flex items-center justify-between gap-4"> 127 + <h2 class="text-lg font-medium text-foreground-default">SSH keys</h2> 128 + <Button variant="primary" icon={Plus} popovertarget="add-key-popover">Add</Button> 129 + </div> 130 + <p class="pt-4 text-sm text-foreground-subtle"> 131 + SSH public keys added here will be broadcasted to knots that you are a member of, allowing you 132 + to push to repositories there and verify commit signatures. 133 + </p> 134 + </div> 135 + 136 + <div 137 + bind:this={popover} 138 + id="add-key-popover" 139 + popover="auto" 140 + ontoggle={(e) => { 141 + if ((e as ToggleEvent).newState === "closed") resetForm(); 142 + }} 143 + class="m-auto w-96 max-w-[90vw] rounded border border-border-default bg-background-default p-4 text-foreground-default shadow-lg backdrop:bg-black/40" 144 + > 145 + <AddKeyForm 146 + bind:name 147 + bind:key 148 + {submitting} 149 + error={formError} 150 + onSubmit={add} 151 + onCancel={() => popover?.hidePopover()} 152 + /> 153 + </div> 154 + 155 + {#if loadError} 156 + <ErrorAlert label={loadError} /> 157 + {/if} 158 + 159 + {#if loading} 160 + <div class="flex items-center justify-center gap-2 p-4 text-sm text-foreground-subtle"> 161 + <Spinner /> Loading… 162 + </div> 163 + {:else} 164 + <Section 165 + empty={keys.length === 0} 166 + emptyMessage="No keys added yet." 167 + listClass="flex flex-col gap-4" 168 + > 169 + {#each keys as k (k.rkey)} 170 + <KeyCard 171 + name={k.value.name} 172 + fingerprint={fingerprints[k.rkey]} 173 + createdAt={k.value.createdAt} 174 + deleting={deleting === k.rkey} 175 + onDelete={() => remove(k)} 176 + /> 177 + {/each} 178 + </Section> 179 + {/if} 180 + </div>
+13
web/src/lib/components/settings/tabs/KnotsTab.svelte
··· 1 + <div> 2 + <h2 class="text-lg font-medium text-foreground-default">Knots</h2> 3 + <p class="text-sm text-foreground-subtle"> 4 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 5 + When creating a repository, you can choose a knot to store it on. 6 + </p> 7 + <!-- TODO: register and manage knots --> 8 + <div 9 + class="mt-2 flex items-center justify-center rounded border border-border-default p-4 text-sm text-foreground-subtle" 10 + > 11 + Coming soon 12 + </div> 13 + </div>
+12
web/src/lib/components/settings/tabs/SpindlesTab.svelte
··· 1 + <div> 2 + <h2 class="text-lg font-medium text-foreground-default">Spindles</h2> 3 + <p class="text-sm text-foreground-subtle"> 4 + Spindles are servers that run continuous integration pipelines for your repositories. 5 + </p> 6 + <!-- TODO: register and manage spindles --> 7 + <div 8 + class="mt-2 flex items-center justify-center rounded border border-border-default p-4 text-sm text-foreground-subtle" 9 + > 10 + Coming soon 11 + </div> 12 + </div>
+5
web/src/routes/settings/keys/+page.svelte
··· 1 + <script lang="ts"> 2 + import KeysTab from "$lib/components/settings/tabs/KeysTab.svelte"; 3 + </script> 4 + 5 + <KeysTab />
+5
web/src/routes/settings/knots/+page.svelte
··· 1 + <script lang="ts"> 2 + import KnotsTab from "$lib/components/settings/tabs/KnotsTab.svelte"; 3 + </script> 4 + 5 + <KnotsTab />