@axium/server 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,47 @@
1
1
  <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import ClipboardCopy from '$lib/ClipboardCopy.svelte';
2
4
  import FormDialog from '$lib/FormDialog.svelte';
3
5
  import Icon from '$lib/icons/Icon.svelte';
4
6
  import {
5
- currentSession,
7
+ createPasskey,
6
8
  deletePasskey,
9
+ deleteUser,
10
+ emailVerificationEnabled,
11
+ getCurrentSession,
7
12
  getPasskeys,
13
+ getSessions,
14
+ logout,
15
+ logoutAll,
8
16
  sendVerificationEmail,
9
17
  updatePasskey,
10
18
  updateUser,
11
- createPasskey,
12
- deleteUser,
13
19
  } from '@axium/client/user';
14
- import type { Passkey } from '@axium/core/api';
20
+ import type { Passkey, Session } from '@axium/core/api';
15
21
  import { getUserImage, type User } from '@axium/core/user';
16
22
 
17
23
  const dialogs = $state<Record<string, HTMLDialogElement>>({});
18
24
 
19
25
  let verificationSent = $state(false);
26
+ let currentSession = $state<Session & { user: User }>();
20
27
  let user = $state<User>();
28
+ let canVerify = $state(false);
29
+ let passkeys = $state<Passkey[]>([]);
30
+ let sessions = $state<Session[]>([]);
21
31
 
22
32
  async function ready() {
23
- const session = await currentSession();
24
- user = session.user;
33
+ currentSession = await getCurrentSession().catch(() => {
34
+ goto('/login?after=/account');
35
+ return null;
36
+ })!;
37
+ user = currentSession.user;
25
38
 
26
39
  passkeys = await getPasskeys(user.id);
27
- }
28
40
 
29
- let passkeys = $state<Passkey[]>([]);
41
+ sessions = await getSessions(user.id);
42
+
43
+ canVerify = await emailVerificationEnabled(user.id);
44
+ }
30
45
 
31
46
  async function _editUser(data) {
32
47
  const result = await updateUser(user.id, data);
@@ -39,14 +54,8 @@
39
54
  </svelte:head>
40
55
 
41
56
  {#snippet action(name: string, i: string = 'pen')}
42
- <button
43
- style:display="contents"
44
- style:cursor="pointer"
45
- onclick={() => {
46
- dialogs[name].showModal();
47
- }}
48
- >
49
- <Icon {i} />
57
+ <button style:display="contents" onclick={() => dialogs[name].showModal()}>
58
+ <Icon {i} --size="16px" />
50
59
  </button>
51
60
  {/snippet}
52
61
 
@@ -56,9 +65,10 @@
56
65
  <p class="greeting">Welcome, {user.name}</p>
57
66
 
58
67
  <div class="section main">
59
- <div class="item">
60
- <span class="subtle">Name</span>
61
- <span>{user.name}</span>
68
+ <h3>Personal Information</h3>
69
+ <div class="item info">
70
+ <p class="subtle">Name</p>
71
+ <p>{user.name}</p>
62
72
  {@render action('edit_name')}
63
73
  </div>
64
74
  <FormDialog bind:dialog={dialogs.edit_name} submit={_editUser} submitText="Change">
@@ -67,20 +77,20 @@
67
77
  <input name="name" type="text" value={user.name || ''} required />
68
78
  </div>
69
79
  </FormDialog>
70
- <div class="item">
71
- <span class="subtle">Email</span>
72
- <span>
80
+ <div class="item info">
81
+ <p class="subtle">Email</p>
82
+ <p>
73
83
  {user.email}
74
84
  {#if user.emailVerified}
75
- <dfn title="Email verified on {new Date(user.emailVerified).toLocaleDateString()}">
85
+ <dfn title="Email verified on {user.emailVerified.toLocaleDateString()}">
76
86
  <Icon i="regular/circle-check" />
77
87
  </dfn>
78
- {:else}
88
+ {:else if canVerify}
79
89
  <button onclick={() => sendVerificationEmail(user.id).then(() => (verificationSent = true))}>
80
90
  {verificationSent ? 'Verification email sent' : 'Verify'}
81
91
  </button>
82
92
  {/if}
83
- </span>
93
+ </p>
84
94
  {@render action('edit_email')}
85
95
  </div>
86
96
  <FormDialog bind:dialog={dialogs.edit_email} submit={_editUser} submitText="Change">
@@ -90,16 +100,20 @@
90
100
  </div>
91
101
  </FormDialog>
92
102
 
93
- <div class="item">
94
- <p class="subtle">User ID <dfn title="This is your UUID."><Icon i="regular/circle-info" /></dfn></p>
103
+ <div class="item info">
104
+ <p class="subtle">User ID <dfn title="This is your UUID. It can't be changed."><Icon i="regular/circle-info" /></dfn></p>
95
105
  <p>{user.id}</p>
106
+ <ClipboardCopy value={user.id} --size="16px" />
96
107
  </div>
97
108
  <span>
98
109
  <a class="signout" href="/logout"><button>Sign out</button></a>
99
- <button style:cursor="pointer" onclick={() => dialogs.delete.showModal()} style:width="fit-content" class="danger"
100
- >Delete Account</button
110
+ <button style:cursor="pointer" onclick={() => dialogs.delete.showModal()} class="danger">Delete Account</button>
111
+ <FormDialog
112
+ bind:dialog={dialogs.delete}
113
+ submit={() => deleteUser(user.id).then(() => goto('/'))}
114
+ submitText="Delete Account"
115
+ submitDanger
101
116
  >
102
- <FormDialog bind:dialog={dialogs.delete} submit={() => deleteUser(user.id)} submitText="Delete Account" submitDanger>
103
117
  <p>Are you sure you want to delete your account?<br />This action can't be undone.</p>
104
118
  </FormDialog>
105
119
  </span>
@@ -108,25 +122,25 @@
108
122
  <div class="section main">
109
123
  <h3>Passkeys</h3>
110
124
  {#each passkeys as passkey}
111
- <div class="passkey">
125
+ <div class="item passkey">
112
126
  <dfn title={passkey.deviceType == 'multiDevice' ? 'Multiple devices' : 'Single device'}>
113
- <Icon i={passkey.deviceType == 'multiDevice' ? 'laptop-mobile' : 'mobile'} />
127
+ <Icon i={passkey.deviceType == 'multiDevice' ? 'laptop-mobile' : 'mobile'} --size="16px" />
114
128
  </dfn>
115
129
  <dfn title="This passkey is {passkey.backedUp ? '' : 'not '}backed up">
116
- <Icon i={passkey.backedUp ? 'circle-check' : 'circle-xmark'} />
130
+ <Icon i={passkey.backedUp ? 'circle-check' : 'circle-xmark'} --size="16px" />
117
131
  </dfn>
118
- <p>Created {new Date(passkey.createdAt).toLocaleString()}</p>
119
132
  {#if passkey.name}
120
133
  <p>{passkey.name}</p>
121
134
  {:else}
122
135
  <p class="subtle"><i>Unnamed</i></p>
123
136
  {/if}
137
+ <p>Created {passkey.createdAt.toLocaleString()}</p>
124
138
  {@render action('edit_passkey#' + passkey.id)}
125
139
  {#if passkeys.length > 1}
126
140
  {@render action('delete_passkey#' + passkey.id, 'trash')}
127
141
  {:else}
128
142
  <dfn title="You must have at least one passkey" class="disabled">
129
- <Icon i="trash-slash" --fill="#888" />
143
+ <Icon i="trash-slash" --fill="#888" --size="16px" />
130
144
  </dfn>
131
145
  {/if}
132
146
  </div>
@@ -153,12 +167,56 @@
153
167
  <p>Are you sure you want to delete this passkey?<br />This action can't be undone.</p>
154
168
  </FormDialog>
155
169
  {/each}
156
- <button onclick={() => createPasskey(user.id).then(passkeys.push.bind(passkeys))}><Icon i="plus" /> Create</button>
170
+ <span>
171
+ <button onclick={() => createPasskey(user.id).then(passkeys.push.bind(passkeys))}><Icon i="plus" /> Create</button>
172
+ </span>
173
+ </div>
174
+
175
+ <div class="section main">
176
+ <h3>Sessions</h3>
177
+ {#each sessions as session}
178
+ <div class="item session">
179
+ <p>
180
+ {session.id.slice(0, 4)}...{session.id.slice(-4)}
181
+ {#if session.id == currentSession.id}
182
+ <span class="current">Current</span>
183
+ {/if}
184
+ {#if session.elevated}
185
+ <span class="elevated">Elevated</span>
186
+ {/if}
187
+ </p>
188
+ <p>Created {session.created.toLocaleString()}</p>
189
+ <p>Expires {session.expires.toLocaleString()}</p>
190
+ {@render action('logout#' + session.id, 'right-from-bracket')}
191
+ </div>
192
+ <FormDialog
193
+ bind:dialog={dialogs['logout#' + session.id]}
194
+ submit={() =>
195
+ logout(user.id, session.id).then(() => {
196
+ if (session.id == currentSession.id) goto('/');
197
+ else sessions.splice(sessions.indexOf(session), 1);
198
+ })}
199
+ submitText="Logout"
200
+ >
201
+ <p>Are you sure you want to log out this session?</p>
202
+ </FormDialog>
203
+ {/each}
204
+ <span>
205
+ <button onclick={() => dialogs.logout_all.showModal()} class="danger">Logout All</button>
206
+ </span>
207
+ <FormDialog
208
+ bind:dialog={dialogs['logout_all']}
209
+ submit={() => logoutAll(user.id).then(() => goto('/'))}
210
+ submitText="Logout All Sessions"
211
+ submitDanger
212
+ >
213
+ <p>Are you sure you want to log out all sessions?</p>
214
+ </FormDialog>
157
215
  </div>
158
216
  </div>
159
217
  {:catch error}
160
218
  <div class="error">
161
- <h3>Failed to load your account</h3>
219
+ <h3>Failed to load account</h3>
162
220
  <p>{'message' in error ? error.message : error}</p>
163
221
  </div>
164
222
  {/await}
@@ -191,12 +249,16 @@
191
249
 
192
250
  .section .item {
193
251
  display: grid;
194
- grid-template-columns: 10em 1fr 2em;
195
252
  align-items: center;
196
253
  width: 100%;
197
254
  gap: 1em;
198
255
  text-wrap: nowrap;
256
+ border-top: 1px solid #8888;
199
257
  padding-bottom: 1em;
258
+ }
259
+
260
+ .info {
261
+ grid-template-columns: 10em 1fr 2em;
200
262
 
201
263
  > :first-child {
202
264
  margin-left: 1em;
@@ -204,21 +266,26 @@
204
266
  }
205
267
 
206
268
  .passkey {
207
- display: grid;
208
269
  grid-template-columns: 1em 1em 1fr 1fr 1em 1em;
209
- border-top: 1px solid #8888;
210
- align-items: center;
211
- width: 100%;
212
- gap: 1em;
213
- text-wrap: nowrap;
214
- padding-bottom: 1em;
215
270
 
216
- dfn {
271
+ dfn:not(.disabled) {
217
272
  cursor: help;
218
273
  }
274
+ }
275
+
276
+ .session {
277
+ grid-template-columns: 1fr 1fr 1fr 1em;
278
+
279
+ .current {
280
+ border-radius: 2em;
281
+ padding: 0 0.5em;
282
+ background-color: #337;
283
+ }
219
284
 
220
- dfn.disabled {
221
- cursor: not-allowed;
285
+ .elevated {
286
+ border-radius: 2em;
287
+ padding: 0 0.5em;
288
+ background-color: #733;
222
289
  }
223
290
  }
224
291
  </style>
@@ -1,6 +1,6 @@
1
1
  import type { RequestMethod } from '@axium/core/requests';
2
- import { resolveRoute } from '@axium/server/routes.js';
3
- import { config } from '@axium/server/config.js';
2
+ import { resolveRoute } from '@axium/server/routes';
3
+ import { config } from '@axium/server/config';
4
4
  import { error, json, type RequestEvent, type RequestHandler } from '@sveltejs/kit';
5
5
  import z from 'zod/v4';
6
6