this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4 import { api, type InviteCode, ApiError } from '../lib/api'
5 import { _ } from '../lib/i18n'
6 import { formatDate } from '../lib/date'
7 import { onMount } from 'svelte'
8 import type { Session } from '../lib/types/api'
9 import { toast } from '../lib/toast.svelte'
10
11 const auth = $derived(getAuthState())
12
13 function getSession(): Session | null {
14 return auth.kind === 'authenticated' ? auth.session : null
15 }
16
17 function isLoading(): boolean {
18 return auth.kind === 'loading'
19 }
20
21 const session = $derived(getSession())
22 const authLoading = $derived(isLoading())
23 let codes = $state<InviteCode[]>([])
24 let loading = $state(true)
25 let creating = $state(false)
26 let createdCode = $state<string | null>(null)
27 let createdCodeCopied = $state(false)
28 let copiedCode = $state<string | null>(null)
29 let inviteCodesEnabled = $state<boolean | null>(null)
30
31 onMount(async () => {
32 try {
33 const serverInfo = await api.describeServer()
34 inviteCodesEnabled = serverInfo.inviteCodeRequired
35 if (!serverInfo.inviteCodeRequired) {
36 navigate(routes.dashboard)
37 }
38 } catch {
39 navigate(routes.dashboard)
40 }
41 })
42
43 $effect(() => {
44 if (!authLoading && !session) {
45 navigate(routes.login)
46 }
47 })
48 $effect(() => {
49 if (session && inviteCodesEnabled) {
50 loadCodes()
51 }
52 })
53 async function loadCodes() {
54 if (!session) return
55 loading = true
56 try {
57 const result = await api.getAccountInviteCodes(session.accessJwt)
58 codes = result.codes
59 } catch (e) {
60 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToLoad'))
61 } finally {
62 loading = false
63 }
64 }
65 async function handleCreate() {
66 if (!session) return
67 creating = true
68 try {
69 const result = await api.createInviteCode(session.accessJwt, 1)
70 createdCode = result.code
71 await loadCodes()
72 } catch (e) {
73 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToCreate'))
74 } finally {
75 creating = false
76 }
77 }
78 function dismissCreated() {
79 createdCode = null
80 createdCodeCopied = false
81 }
82 function copyCreatedCode() {
83 if (createdCode) {
84 navigator.clipboard.writeText(createdCode)
85 createdCodeCopied = true
86 }
87 }
88 function copyCode(code: string) {
89 navigator.clipboard.writeText(code)
90 copiedCode = code
91 setTimeout(() => {
92 if (copiedCode === code) {
93 copiedCode = null
94 }
95 }, 2000)
96 }
97</script>
98<div class="page">
99 <header>
100 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
101 <h1>{$_('inviteCodes.title')}</h1>
102 </header>
103 <p class="description">
104 {$_('inviteCodes.description')}
105 </p>
106 {#if createdCode}
107 <div class="created-code">
108 <h3>{$_('inviteCodes.created')}</h3>
109 <div class="code-display">
110 <code>{createdCode}</code>
111 <button class="copy" onclick={copyCreatedCode}>
112 {createdCodeCopied ? $_('common.copied') : $_('common.copyToClipboard')}
113 </button>
114 </div>
115 <button onclick={dismissCreated}>{$_('common.done')}</button>
116 </div>
117 {/if}
118 {#if session?.isAdmin}
119 <section class="create-section">
120 <button onclick={handleCreate} disabled={creating}>
121 {creating ? $_('common.creating') : $_('inviteCodes.createNew')}
122 </button>
123 </section>
124 {/if}
125 <section class="list-section">
126 <h2>{$_('inviteCodes.yourCodes')}</h2>
127 {#if loading}
128 <ul class="code-list">
129 {#each Array(2) as _}
130 <li class="skeleton-item"></li>
131 {/each}
132 </ul>
133 {:else if codes.length === 0}
134 <p class="empty">{$_('inviteCodes.noCodes')}</p>
135 {:else}
136 <ul class="code-list">
137 {#each codes as code}
138 <li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}>
139 <div class="code-main">
140 <code>{code.code}</code>
141 <button
142 class="copy-small"
143 onclick={() => copyCode(code.code)}
144 title={copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')}
145 >
146 {copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')}
147 </button>
148 </div>
149 <div class="code-meta">
150 <span class="date">{$_('inviteCodes.createdOn', { values: { date: formatDate(code.createdAt) } })}</span>
151 {#if code.disabled}
152 <span class="status disabled">{$_('inviteCodes.disabled')}</span>
153 {:else if code.uses.length > 0}
154 <span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span>
155 {:else}
156 <span class="status available">{$_('inviteCodes.available')}</span>
157 {/if}
158 </div>
159 </li>
160 {/each}
161 </ul>
162 {/if}
163 </section>
164</div>
165<style>
166 .page {
167 max-width: var(--width-lg);
168 margin: 0 auto;
169 padding: var(--space-7);
170 }
171
172 header {
173 margin-bottom: var(--space-4);
174 }
175
176 .back {
177 color: var(--text-secondary);
178 text-decoration: none;
179 font-size: var(--text-sm);
180 }
181
182 .back:hover {
183 color: var(--accent);
184 }
185
186 h1 {
187 margin: var(--space-2) 0 0 0;
188 }
189
190 .description {
191 color: var(--text-secondary);
192 margin-bottom: var(--space-7);
193 }
194
195 .error {
196 padding: var(--space-3);
197 background: var(--error-bg);
198 border: 1px solid var(--error-border);
199 border-radius: var(--radius-md);
200 color: var(--error-text);
201 margin-bottom: var(--space-4);
202 }
203
204 .created-code {
205 padding: var(--space-6);
206 background: var(--success-bg);
207 border: 1px solid var(--success-border);
208 border-radius: var(--radius-xl);
209 margin-bottom: var(--space-7);
210 }
211
212 .created-code h3 {
213 margin: 0 0 var(--space-4) 0;
214 color: var(--success-text);
215 }
216
217 .code-display {
218 display: flex;
219 align-items: center;
220 gap: var(--space-4);
221 background: var(--bg-card);
222 padding: var(--space-4);
223 border-radius: var(--radius-md);
224 margin-bottom: var(--space-4);
225 }
226
227 .code-display code {
228 font-size: var(--text-lg);
229 font-family: ui-monospace, monospace;
230 flex: 1;
231 }
232
233 .copy {
234 padding: var(--space-2) var(--space-4);
235 background: var(--accent);
236 color: var(--text-inverse);
237 border: none;
238 border-radius: var(--radius-md);
239 cursor: pointer;
240 }
241
242 .copy:hover {
243 background: var(--accent-hover);
244 }
245
246 .create-section {
247 margin-bottom: var(--space-7);
248 }
249
250 section h2 {
251 font-size: var(--text-lg);
252 margin: 0 0 var(--space-4) 0;
253 }
254
255 .code-list {
256 list-style: none;
257 padding: 0;
258 margin: 0;
259 }
260
261 .code-list li {
262 padding: var(--space-4);
263 border: 1px solid var(--border-color);
264 border-radius: var(--radius-md);
265 margin-bottom: var(--space-2);
266 background: var(--bg-card);
267 }
268
269 .code-list li.disabled {
270 opacity: 0.6;
271 }
272
273 .code-list li.used {
274 background: var(--bg-secondary);
275 }
276
277 .code-main {
278 display: flex;
279 align-items: center;
280 gap: var(--space-2);
281 margin-bottom: var(--space-2);
282 }
283
284 .code-main code {
285 font-family: ui-monospace, monospace;
286 font-size: var(--text-sm);
287 }
288
289 .copy-small {
290 padding: var(--space-1) var(--space-2);
291 background: var(--bg-secondary);
292 border: 1px solid var(--border-color);
293 border-radius: var(--radius-md);
294 font-size: var(--text-xs);
295 cursor: pointer;
296 color: var(--text-primary);
297 }
298
299 .copy-small:hover {
300 background: var(--bg-input-disabled);
301 }
302
303 .code-meta {
304 display: flex;
305 gap: var(--space-4);
306 font-size: var(--text-sm);
307 }
308
309 .date {
310 color: var(--text-secondary);
311 }
312
313 .status {
314 padding: var(--space-1) var(--space-2);
315 border-radius: var(--radius-md);
316 font-size: var(--text-xs);
317 }
318
319 .status.available {
320 background: var(--success-bg);
321 color: var(--success-text);
322 }
323
324 .status.used {
325 background: var(--bg-secondary);
326 color: var(--text-secondary);
327 }
328
329 .status.disabled {
330 background: var(--error-bg);
331 color: var(--error-text);
332 }
333
334 .empty {
335 color: var(--text-secondary);
336 text-align: center;
337 padding: var(--space-7);
338 }
339
340 .skeleton-item {
341 height: 50px;
342 background: var(--bg-tertiary);
343 animation: skeleton-pulse 1.5s ease-in-out infinite;
344 }
345
346 @keyframes skeleton-pulse {
347 0%, 100% { opacity: 1; }
348 50% { opacity: 0.5; }
349 }
350</style>