@frigear-nu/account 0.0.6 → 0.0.8
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.
- package/README.md +20 -6
- package/dist/module.d.mts +31 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +41 -36
- package/dist/runtime/app/components/Account/AccountHeader.d.vue.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeader.vue +59 -0
- package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +3 -0
- package/dist/runtime/app/components/Account/AccountMenu.vue +7 -2
- package/dist/runtime/app/components/Account/IdentificationCode.d.vue.ts +3 -0
- package/dist/runtime/app/components/Account/IdentificationCode.vue +82 -0
- package/dist/runtime/app/components/Account/IdentificationCode.vue.d.ts +3 -0
- package/dist/runtime/app/components/Account/IdentificationCodeDialog.d.vue.ts +20 -0
- package/dist/runtime/app/components/Account/IdentificationCodeDialog.vue +16 -0
- package/dist/runtime/app/components/Account/IdentificationCodeDialog.vue.d.ts +20 -0
- package/dist/runtime/app/composables/useAccount.js +3 -3
- package/dist/runtime/app/pages/api-tokens.d.vue.ts +3 -0
- package/dist/runtime/app/pages/api-tokens.vue +308 -0
- package/dist/runtime/app/pages/api-tokens.vue.d.ts +3 -0
- package/dist/runtime/app/pages/documents.d.vue.ts +3 -0
- package/dist/runtime/app/pages/documents.vue +38 -0
- package/dist/runtime/app/pages/documents.vue.d.ts +3 -0
- package/dist/runtime/app/pages/id-code.d.vue.ts +3 -0
- package/dist/runtime/app/pages/id-code.vue +13 -0
- package/dist/runtime/app/pages/id-code.vue.d.ts +3 -0
- package/dist/runtime/app/pages/layout.d.vue.ts +3 -0
- package/dist/runtime/app/pages/layout.vue +10 -0
- package/dist/runtime/app/pages/layout.vue.d.ts +3 -0
- package/dist/runtime/app/pages/profile.vue +188 -46
- package/dist/runtime/app/pages/security.vue +180 -60
- package/dist/runtime/app/utils/documents.d.ts +5 -0
- package/dist/runtime/app/utils/documents.js +7 -0
- package/dist/runtime/i18n/lang/da.json +138 -14
- package/dist/runtime/i18n/lang/en.json +138 -14
- package/dist/runtime/menus.js +42 -0
- package/dist/runtime/server/routes/account/id-code.get.js +20 -0
- package/package.json +25 -11
- package/dist/runtime/server/db/schema.sqlite.d.ts +0 -24
- package/dist/runtime/server/db/schema.sqlite.js +0 -4
- package/dist/runtime/server/routes/account/change-email.post.js +0 -20
- package/dist/runtime/server/routes/account/change-password.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/change-password.post.js +0 -26
- package/dist/runtime/server/routes/account/profile.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/profile.post.js +0 -17
- /package/dist/runtime/server/routes/account/{change-email.post.d.ts → id-code.get.d.ts} +0 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
defineOptions({ name: "AccountApiTokensPage" });
|
|
4
|
+
const t = useModuleT("account.apiTokens");
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
const { data: tokens, refresh } = await useFetch("/api/auth/tokens", { key: "account-api-tokens", default: () => [] });
|
|
7
|
+
const { data: scopes } = await useFetch("/api/auth/scopes", { key: "account-api-token-scopes", default: () => [] });
|
|
8
|
+
const scopeItems = computed(() => (scopes.value ?? []).map((scope) => ({
|
|
9
|
+
label: scope.name === "*" ? t("allScopes", "Full access") : scope.name,
|
|
10
|
+
description: scope.description,
|
|
11
|
+
value: scope.name
|
|
12
|
+
})));
|
|
13
|
+
const expiryItems = computed(() => [
|
|
14
|
+
{ label: t("expiry.30", "30 days"), value: 30 },
|
|
15
|
+
{ label: t("expiry.90", "90 days"), value: 90 },
|
|
16
|
+
{ label: t("expiry.365", "1 year"), value: 365 },
|
|
17
|
+
{ label: t("expiry.never", "No expiry"), value: 0 }
|
|
18
|
+
]);
|
|
19
|
+
const schema = z.object({
|
|
20
|
+
name: z.string().trim().min(1, t("name.required", "Give the token a name.")).max(80),
|
|
21
|
+
scopes: z.array(z.string()).min(1, t("scopes.required", "Pick at least one scope.")),
|
|
22
|
+
expiresInDays: z.number()
|
|
23
|
+
});
|
|
24
|
+
const state = reactive({ name: "", scopes: [], expiresInDays: 90 });
|
|
25
|
+
const creating = ref(false);
|
|
26
|
+
const revokingId = ref(null);
|
|
27
|
+
const revealed = ref(null);
|
|
28
|
+
function errorMessage(e, fallback) {
|
|
29
|
+
const err = e;
|
|
30
|
+
return err?.data?.statusMessage ?? err?.statusMessage ?? fallback;
|
|
31
|
+
}
|
|
32
|
+
async function onCreate(event) {
|
|
33
|
+
creating.value = true;
|
|
34
|
+
try {
|
|
35
|
+
const created = await $fetch("/api/auth/tokens", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
body: { name: event.data.name, scopes: event.data.scopes, expiresInDays: event.data.expiresInDays || null }
|
|
38
|
+
});
|
|
39
|
+
revealed.value = { name: created.name, token: created.token };
|
|
40
|
+
state.name = "";
|
|
41
|
+
state.scopes = [];
|
|
42
|
+
await refresh();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
toast.add({ title: errorMessage(e, t("createError", "Could not create the token.")), color: "error" });
|
|
45
|
+
} finally {
|
|
46
|
+
creating.value = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function copyText(text, success) {
|
|
50
|
+
try {
|
|
51
|
+
await navigator.clipboard.writeText(text);
|
|
52
|
+
toast.add({ title: success, color: "success" });
|
|
53
|
+
} catch {
|
|
54
|
+
toast.add({ title: t("copyError", "Could not copy. Select the token and copy it manually."), color: "warning" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const copyToken = () => revealed.value && copyText(revealed.value.token, t("copied", "Token copied."));
|
|
58
|
+
async function onRevoke(token) {
|
|
59
|
+
revokingId.value = token.id;
|
|
60
|
+
try {
|
|
61
|
+
await $fetch(`/api/auth/tokens/${token.id}`, { method: "DELETE" });
|
|
62
|
+
toast.add({ title: t("revoked", "Token revoked."), color: "success" });
|
|
63
|
+
await refresh();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
toast.add({ title: errorMessage(e, t("revokeError", "Could not revoke the token.")), color: "error" });
|
|
66
|
+
} finally {
|
|
67
|
+
revokingId.value = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const mcpEnabled = useRuntimeConfig().public.users?.mcp === true;
|
|
71
|
+
const activeConnect = ref("claude");
|
|
72
|
+
const mcpUrl = `${useRequestURL().origin}/mcp`;
|
|
73
|
+
const tokenPlaceholder = "<your-api-token>";
|
|
74
|
+
const shownToken = computed(() => revealed.value?.token ?? tokenPlaceholder);
|
|
75
|
+
const connectTabs = computed(() => [
|
|
76
|
+
{
|
|
77
|
+
label: "Claude",
|
|
78
|
+
value: "claude",
|
|
79
|
+
steps: t("connect.claude.steps", "Claude.ai and Claude Desktop: open Settings \u2192 Connectors \u2192 Add custom connector, paste the URL below and sign in when asked. No token needed."),
|
|
80
|
+
code: mcpUrl,
|
|
81
|
+
extraSteps: t("connect.claude.cli", "Claude Code (uses an API token):"),
|
|
82
|
+
extraCode: `claude mcp add --transport http frigear ${mcpUrl} --header "Authorization: Bearer ${shownToken.value}"`
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
label: "ChatGPT",
|
|
86
|
+
value: "chatgpt",
|
|
87
|
+
steps: t("connect.chatgpt.steps", "ChatGPT: open Settings \u2192 Connectors \u2192 Advanced, turn on Developer mode, then create a connector with the URL below. Choose OAuth and sign in when asked. No token needed."),
|
|
88
|
+
code: mcpUrl
|
|
89
|
+
}
|
|
90
|
+
]);
|
|
91
|
+
const dateFormatter = new Intl.DateTimeFormat(void 0, { dateStyle: "medium" });
|
|
92
|
+
function formatDate(value) {
|
|
93
|
+
if (!value) return "";
|
|
94
|
+
const date = new Date(value);
|
|
95
|
+
return Number.isNaN(date.getTime()) ? "" : dateFormatter.format(date);
|
|
96
|
+
}
|
|
97
|
+
function statusColor(status) {
|
|
98
|
+
return status === "ok" ? "success" : status === "expired" ? "warning" : "neutral";
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<UContainer>
|
|
104
|
+
<UPage>
|
|
105
|
+
<UPageBody>
|
|
106
|
+
<UPageCard
|
|
107
|
+
:title="t('create.title', 'Create an API token')"
|
|
108
|
+
:description="t('create.description', 'Tokens let scripts and tools such as MCP clients act as you. Give each one only the scopes it needs.')"
|
|
109
|
+
variant="subtle"
|
|
110
|
+
>
|
|
111
|
+
<UAlert
|
|
112
|
+
v-if="revealed"
|
|
113
|
+
color="success"
|
|
114
|
+
variant="subtle"
|
|
115
|
+
icon="i-lucide-key-round"
|
|
116
|
+
:title="t('reveal.title', 'Copy your new token now')"
|
|
117
|
+
:description="t('reveal.description', 'This is the only time it is shown. If you lose it, create a new one.')"
|
|
118
|
+
>
|
|
119
|
+
<template #actions>
|
|
120
|
+
<UInput
|
|
121
|
+
:model-value="revealed.token"
|
|
122
|
+
readonly
|
|
123
|
+
class="w-full sm:w-96"
|
|
124
|
+
:aria-label="t('reveal.label', 'New token')"
|
|
125
|
+
@focus="$event.target.select()"
|
|
126
|
+
/>
|
|
127
|
+
<UButton
|
|
128
|
+
icon="i-lucide-copy"
|
|
129
|
+
:label="t('copy', 'Copy')"
|
|
130
|
+
@click="copyToken"
|
|
131
|
+
/>
|
|
132
|
+
<UButton
|
|
133
|
+
color="neutral"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
:label="t('reveal.done', 'Done')"
|
|
136
|
+
@click="revealed = null"
|
|
137
|
+
/>
|
|
138
|
+
</template>
|
|
139
|
+
</UAlert>
|
|
140
|
+
|
|
141
|
+
<UForm
|
|
142
|
+
:schema="schema"
|
|
143
|
+
:state="state"
|
|
144
|
+
class="space-y-4"
|
|
145
|
+
@submit="onCreate"
|
|
146
|
+
>
|
|
147
|
+
<UFormField
|
|
148
|
+
:label="t('name.label', 'Name')"
|
|
149
|
+
name="name"
|
|
150
|
+
required
|
|
151
|
+
>
|
|
152
|
+
<UInput
|
|
153
|
+
v-model="state.name"
|
|
154
|
+
:placeholder="t('name.placeholder', 'e.g. My MCP client')"
|
|
155
|
+
maxlength="80"
|
|
156
|
+
class="w-full"
|
|
157
|
+
/>
|
|
158
|
+
</UFormField>
|
|
159
|
+
<UFormField
|
|
160
|
+
:label="t('scopes.label', 'Scopes')"
|
|
161
|
+
name="scopes"
|
|
162
|
+
required
|
|
163
|
+
>
|
|
164
|
+
<USelectMenu
|
|
165
|
+
v-model="state.scopes"
|
|
166
|
+
:items="scopeItems"
|
|
167
|
+
value-key="value"
|
|
168
|
+
multiple
|
|
169
|
+
:placeholder="t('scopes.placeholder', 'Choose what the token may do')"
|
|
170
|
+
class="w-full"
|
|
171
|
+
/>
|
|
172
|
+
</UFormField>
|
|
173
|
+
<UFormField
|
|
174
|
+
:label="t('expiry.label', 'Expires')"
|
|
175
|
+
name="expiresInDays"
|
|
176
|
+
>
|
|
177
|
+
<USelect
|
|
178
|
+
v-model="state.expiresInDays"
|
|
179
|
+
:items="expiryItems"
|
|
180
|
+
value-key="value"
|
|
181
|
+
class="w-full sm:max-w-xs"
|
|
182
|
+
/>
|
|
183
|
+
</UFormField>
|
|
184
|
+
<UButton
|
|
185
|
+
type="submit"
|
|
186
|
+
:loading="creating"
|
|
187
|
+
:label="t('create.submit', 'Create token')"
|
|
188
|
+
/>
|
|
189
|
+
</UForm>
|
|
190
|
+
</UPageCard>
|
|
191
|
+
|
|
192
|
+
<UPageCard
|
|
193
|
+
v-if="mcpEnabled"
|
|
194
|
+
:title="t('connect.title', 'Connect to MCP')"
|
|
195
|
+
:description="t('connect.description', 'Use your account from an AI assistant. Your API token is only filled into the commands right after you create it.')"
|
|
196
|
+
variant="subtle"
|
|
197
|
+
>
|
|
198
|
+
<UTabs
|
|
199
|
+
v-model="activeConnect"
|
|
200
|
+
:items="connectTabs"
|
|
201
|
+
:content="false"
|
|
202
|
+
class="w-full"
|
|
203
|
+
/>
|
|
204
|
+
<template
|
|
205
|
+
v-for="tab in connectTabs"
|
|
206
|
+
:key="tab.value"
|
|
207
|
+
>
|
|
208
|
+
<div
|
|
209
|
+
v-if="tab.value === activeConnect"
|
|
210
|
+
class="min-w-0 max-w-full space-y-3"
|
|
211
|
+
>
|
|
212
|
+
<p class="text-sm text-muted">
|
|
213
|
+
{{ tab.steps }}
|
|
214
|
+
</p>
|
|
215
|
+
<div class="relative min-w-0">
|
|
216
|
+
<pre class="max-w-full whitespace-pre-wrap break-all rounded-md bg-elevated p-3 pr-12 text-sm"><code>{{ tab.code }}</code></pre>
|
|
217
|
+
<UButton
|
|
218
|
+
class="absolute right-2 top-2"
|
|
219
|
+
color="neutral"
|
|
220
|
+
variant="ghost"
|
|
221
|
+
size="xs"
|
|
222
|
+
icon="i-lucide-copy"
|
|
223
|
+
:aria-label="t('copy', 'Copy')"
|
|
224
|
+
@click="copyText(tab.code, t('copiedSnippet', 'Copied.'))"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
<template v-if="tab.extraCode">
|
|
228
|
+
<p class="text-sm text-muted">
|
|
229
|
+
{{ tab.extraSteps }}
|
|
230
|
+
</p>
|
|
231
|
+
<div class="relative min-w-0">
|
|
232
|
+
<pre class="max-w-full whitespace-pre-wrap break-all rounded-md bg-elevated p-3 pr-12 text-sm"><code>{{ tab.extraCode }}</code></pre>
|
|
233
|
+
<UButton
|
|
234
|
+
class="absolute right-2 top-2"
|
|
235
|
+
color="neutral"
|
|
236
|
+
variant="ghost"
|
|
237
|
+
size="xs"
|
|
238
|
+
icon="i-lucide-copy"
|
|
239
|
+
:aria-label="t('copy', 'Copy')"
|
|
240
|
+
@click="copyText(tab.extraCode ?? '', t('copiedSnippet', 'Copied.'))"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</template>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
|
246
|
+
</UPageCard>
|
|
247
|
+
|
|
248
|
+
<UPageCard
|
|
249
|
+
:title="t('list.title', 'Your tokens')"
|
|
250
|
+
:description="t('list.description', 'Revoke a token to stop it working immediately.')"
|
|
251
|
+
variant="subtle"
|
|
252
|
+
>
|
|
253
|
+
<ul
|
|
254
|
+
v-if="tokens?.length"
|
|
255
|
+
class="divide-y divide-default"
|
|
256
|
+
>
|
|
257
|
+
<li
|
|
258
|
+
v-for="token in tokens"
|
|
259
|
+
:key="token.id"
|
|
260
|
+
class="flex items-center justify-between gap-3 py-3"
|
|
261
|
+
>
|
|
262
|
+
<div class="min-w-0">
|
|
263
|
+
<p class="flex items-center gap-2 truncate font-medium text-highlighted">
|
|
264
|
+
{{ token.name }}
|
|
265
|
+
<UBadge
|
|
266
|
+
:color="statusColor(token.status)"
|
|
267
|
+
variant="subtle"
|
|
268
|
+
:label="t(`status.${token.status}`, token.status)"
|
|
269
|
+
/>
|
|
270
|
+
</p>
|
|
271
|
+
<p class="font-mono text-sm text-muted">
|
|
272
|
+
{{ token.prefix }}…{{ token.last4 }}
|
|
273
|
+
</p>
|
|
274
|
+
<p class="text-sm text-muted">
|
|
275
|
+
{{ token.scopes.join(", ") }}
|
|
276
|
+
</p>
|
|
277
|
+
<p class="text-sm text-muted">
|
|
278
|
+
{{ t("list.created", "Created {date}", { date: formatDate(token.createdAt) }) }}
|
|
279
|
+
<template v-if="token.lastUsedAt">
|
|
280
|
+
· {{ t("list.lastUsed", "Last used {date}", { date: formatDate(token.lastUsedAt) }) }}
|
|
281
|
+
</template>
|
|
282
|
+
<template v-if="token.expiresAt">
|
|
283
|
+
· {{ t("list.expires", "Expires {date}", { date: formatDate(token.expiresAt) }) }}
|
|
284
|
+
</template>
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
<UButton
|
|
288
|
+
v-if="token.status !== 'revoked'"
|
|
289
|
+
color="error"
|
|
290
|
+
variant="ghost"
|
|
291
|
+
icon="i-lucide-trash-2"
|
|
292
|
+
:loading="revokingId === token.id"
|
|
293
|
+
:aria-label="t('revoke', 'Revoke token')"
|
|
294
|
+
@click="onRevoke(token)"
|
|
295
|
+
/>
|
|
296
|
+
</li>
|
|
297
|
+
</ul>
|
|
298
|
+
<p
|
|
299
|
+
v-else
|
|
300
|
+
class="text-sm text-muted"
|
|
301
|
+
>
|
|
302
|
+
{{ t("list.empty", "You have not created any tokens yet.") }}
|
|
303
|
+
</p>
|
|
304
|
+
</UPageCard>
|
|
305
|
+
</UPageBody>
|
|
306
|
+
</UPage>
|
|
307
|
+
</UContainer>
|
|
308
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { documentLabel, visibleDocuments } from "../utils/documents";
|
|
3
|
+
defineOptions({ name: "AccountDocumentsPage" });
|
|
4
|
+
const t = useModuleT("account.documents");
|
|
5
|
+
const { user } = useUserSession();
|
|
6
|
+
const nuxtApp = useNuxtApp();
|
|
7
|
+
const locale = computed(() => nuxtApp.$i18n?.locale?.value ?? "en");
|
|
8
|
+
const documents = computed(() => visibleDocuments(useRuntimeConfig().public.account?.documents ?? [], user.value?.role));
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<UContainer>
|
|
13
|
+
<UPage>
|
|
14
|
+
<UPageBody>
|
|
15
|
+
<UPageGrid v-if="documents.length">
|
|
16
|
+
<UPageCard
|
|
17
|
+
v-for="document in documents"
|
|
18
|
+
:key="document.id"
|
|
19
|
+
:title="documentLabel(document, locale)"
|
|
20
|
+
:icon="document.icon ?? 'i-lucide-file'"
|
|
21
|
+
>
|
|
22
|
+
<UButton
|
|
23
|
+
download
|
|
24
|
+
external
|
|
25
|
+
:to="document.path"
|
|
26
|
+
icon="i-lucide-download"
|
|
27
|
+
:label="t('download', 'Download')"
|
|
28
|
+
/>
|
|
29
|
+
</UPageCard>
|
|
30
|
+
</UPageGrid>
|
|
31
|
+
<UEmpty
|
|
32
|
+
v-else
|
|
33
|
+
:title="t('empty', 'No documents for you yet.')"
|
|
34
|
+
/>
|
|
35
|
+
</UPageBody>
|
|
36
|
+
</UPage>
|
|
37
|
+
</UContainer>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -1,22 +1,62 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
defineOptions({ name: "AccountProfilePage" });
|
|
3
4
|
const t = useModuleT("account.profile");
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
const route = useRoute();
|
|
4
7
|
const { user } = useUserSession();
|
|
5
|
-
const { updateProfileDetails, changeEmailAddress,
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
watch(user, (newUser) => {
|
|
10
|
-
if (!newUser) return;
|
|
11
|
-
name.value = newUser.name;
|
|
12
|
-
avatar.value = newUser.avatar;
|
|
13
|
-
email.value = newUser.email;
|
|
8
|
+
const { updateProfileDetails, changeEmailAddress, error } = useAccount();
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
name: z.string().trim().min(1, t("name.required", "Please enter your name.")),
|
|
11
|
+
avatar: z.union([z.literal(""), z.url(t("avatar.invalid", "Enter a valid URL."))]).optional()
|
|
14
12
|
});
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const state = reactive({
|
|
14
|
+
name: user.value?.name ?? "",
|
|
15
|
+
avatar: user.value?.avatar ?? ""
|
|
16
|
+
});
|
|
17
|
+
const saved = computed(() => ({ name: user.value?.name ?? "", avatar: user.value?.avatar ?? "" }));
|
|
18
|
+
let lastSynced = { ...saved.value };
|
|
19
|
+
watch(saved, (next) => {
|
|
20
|
+
const pristine = state.name === lastSynced.name && state.avatar === lastSynced.avatar;
|
|
21
|
+
lastSynced = { ...next };
|
|
22
|
+
if (pristine) {
|
|
23
|
+
state.name = next.name;
|
|
24
|
+
state.avatar = next.avatar;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const dirty = computed(() => state.name !== saved.value.name || state.avatar !== saved.value.avatar);
|
|
28
|
+
const saving = ref(false);
|
|
29
|
+
async function onSubmitProfile(event) {
|
|
30
|
+
saving.value = true;
|
|
31
|
+
try {
|
|
32
|
+
await updateProfileDetails({ name: event.data.name, avatar: event.data.avatar || void 0 });
|
|
33
|
+
toast.add({ title: t("saved", "Profile updated"), color: "success" });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
toast.add({ title: e.statusMessage ?? t("saveFailed", "Could not update your profile."), color: "error" });
|
|
36
|
+
} finally {
|
|
37
|
+
saving.value = false;
|
|
38
|
+
}
|
|
17
39
|
}
|
|
40
|
+
const emailModalOpen = ref(false);
|
|
41
|
+
const newEmail = ref("");
|
|
42
|
+
const emailPending = ref(false);
|
|
43
|
+
const emailVerificationSent = ref(false);
|
|
44
|
+
const emailChanged = computed(() => route.query["email-changed"] === "1");
|
|
45
|
+
const emailChangeFailed = computed(() => route.query.error === "email-change");
|
|
46
|
+
const emailSchema = z.object({ email: z.email(t("email.invalid", "Enter a valid email address.")) });
|
|
18
47
|
async function onSubmitEmail() {
|
|
19
|
-
|
|
48
|
+
emailPending.value = true;
|
|
49
|
+
try {
|
|
50
|
+
const result = await changeEmailAddress({ email: newEmail.value });
|
|
51
|
+
emailVerificationSent.value = result.verificationRequired;
|
|
52
|
+
emailModalOpen.value = false;
|
|
53
|
+
newEmail.value = "";
|
|
54
|
+
toast.add({ title: result.verificationRequired ? t("email.change.sent.title", "Check your inbox") : t("email.change.done", "Your email address was changed."), color: "success" });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
toast.add({ title: e.statusMessage ?? t("email.change.error", "Could not change your email address."), color: "error" });
|
|
57
|
+
} finally {
|
|
58
|
+
emailPending.value = false;
|
|
59
|
+
}
|
|
20
60
|
}
|
|
21
61
|
</script>
|
|
22
62
|
|
|
@@ -24,52 +64,154 @@ async function onSubmitEmail() {
|
|
|
24
64
|
<UContainer>
|
|
25
65
|
<UPage>
|
|
26
66
|
<UPageBody>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
<UAlert
|
|
68
|
+
v-if="emailChanged"
|
|
69
|
+
color="success"
|
|
70
|
+
variant="subtle"
|
|
71
|
+
:title="t('email.change.done', 'Your email address was changed.')"
|
|
72
|
+
/>
|
|
73
|
+
<UAlert
|
|
74
|
+
v-if="emailChangeFailed"
|
|
75
|
+
color="error"
|
|
76
|
+
variant="subtle"
|
|
77
|
+
:title="t('email.change.failed', 'This confirmation link is invalid or has expired.')"
|
|
78
|
+
/>
|
|
79
|
+
<UAlert
|
|
80
|
+
v-if="emailVerificationSent"
|
|
81
|
+
variant="subtle"
|
|
82
|
+
:title="t('email.change.sent.title', 'Check your inbox')"
|
|
83
|
+
:description="t('email.change.sent.description', 'We sent a link to your new address. It takes effect once you confirm it.')"
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
<UForm
|
|
87
|
+
id="profile"
|
|
88
|
+
:schema="schema"
|
|
89
|
+
:state="state"
|
|
90
|
+
@submit="onSubmitProfile"
|
|
91
|
+
>
|
|
92
|
+
<UPageCard
|
|
93
|
+
variant="subtle"
|
|
94
|
+
:title="t('title', 'Profile')"
|
|
95
|
+
:description="t('description', 'Change your information.')"
|
|
37
96
|
>
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
97
|
+
<UFormField
|
|
98
|
+
name="name"
|
|
99
|
+
:label="t('name.label', 'Name')"
|
|
100
|
+
:description="t('name.description', 'Your full name.')"
|
|
101
|
+
required
|
|
102
|
+
class="flex items-start justify-between gap-4 max-sm:flex-col"
|
|
103
|
+
>
|
|
104
|
+
<UInput
|
|
105
|
+
v-model="state.name"
|
|
106
|
+
autocomplete="name"
|
|
107
|
+
/>
|
|
108
|
+
</UFormField>
|
|
109
|
+
<USeparator />
|
|
110
|
+
<UFormField
|
|
111
|
+
name="avatar"
|
|
112
|
+
:label="t('avatar.label', 'Avatar')"
|
|
113
|
+
:description="t('avatar.description', 'Link to a square image.')"
|
|
114
|
+
class="flex items-start justify-between gap-4 max-sm:flex-col"
|
|
115
|
+
>
|
|
116
|
+
<div class="flex items-center gap-3">
|
|
117
|
+
<UAvatar
|
|
118
|
+
:src="state.avatar || void 0"
|
|
119
|
+
:alt="state.name"
|
|
120
|
+
size="xl"
|
|
121
|
+
/>
|
|
122
|
+
<UInput
|
|
123
|
+
v-model="state.avatar"
|
|
124
|
+
type="url"
|
|
125
|
+
placeholder="https://"
|
|
126
|
+
:aria-label="t('avatar.url', 'Avatar URL')"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
</UFormField>
|
|
130
|
+
<USeparator />
|
|
131
|
+
<div class="flex justify-end">
|
|
132
|
+
<UButton
|
|
133
|
+
type="submit"
|
|
134
|
+
:loading="saving"
|
|
135
|
+
:disabled="!dirty"
|
|
136
|
+
:label="t('save', 'Save profile')"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
</UPageCard>
|
|
140
|
+
</UForm>
|
|
141
|
+
|
|
47
142
|
<UPageCard
|
|
48
|
-
|
|
49
|
-
:title="t('email.label', 'Email
|
|
143
|
+
variant="subtle"
|
|
144
|
+
:title="t('email.label', 'Email')"
|
|
145
|
+
:description="t('email.description', 'Your email address.')"
|
|
50
146
|
>
|
|
51
|
-
<
|
|
52
|
-
:label="t('email.label', 'Email')"
|
|
53
|
-
name="email"
|
|
54
|
-
>
|
|
147
|
+
<div class="flex items-center justify-between gap-4 max-sm:flex-col max-sm:items-start">
|
|
55
148
|
<UInput
|
|
56
|
-
|
|
149
|
+
:model-value="user?.email ?? ''"
|
|
57
150
|
type="email"
|
|
151
|
+
readonly
|
|
152
|
+
disabled
|
|
153
|
+
class="w-full sm:max-w-xs"
|
|
154
|
+
:aria-label="t('email.label', 'Email')"
|
|
58
155
|
/>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
156
|
+
<UButton
|
|
157
|
+
variant="subtle"
|
|
158
|
+
trailing-icon="i-lucide-pencil"
|
|
159
|
+
:label="t('email.change.title', 'Change email')"
|
|
160
|
+
@click="emailModalOpen = true"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
66
163
|
</UPageCard>
|
|
164
|
+
|
|
67
165
|
<UAlert
|
|
68
166
|
v-if="error"
|
|
69
|
-
class="mt-4"
|
|
70
167
|
color="error"
|
|
168
|
+
variant="subtle"
|
|
71
169
|
:title="error"
|
|
72
170
|
/>
|
|
171
|
+
|
|
172
|
+
<UModal
|
|
173
|
+
v-model:open="emailModalOpen"
|
|
174
|
+
:title="t('email.change.title', 'Change email')"
|
|
175
|
+
>
|
|
176
|
+
<template #body>
|
|
177
|
+
<UForm
|
|
178
|
+
id="change-email"
|
|
179
|
+
:schema="emailSchema"
|
|
180
|
+
:state="{ email: newEmail }"
|
|
181
|
+
class="flex flex-col gap-4"
|
|
182
|
+
@submit="onSubmitEmail"
|
|
183
|
+
>
|
|
184
|
+
<UFormField
|
|
185
|
+
name="email"
|
|
186
|
+
:label="t('email.change.label', 'New e-mail')"
|
|
187
|
+
required
|
|
188
|
+
>
|
|
189
|
+
<UInput
|
|
190
|
+
v-model="newEmail"
|
|
191
|
+
type="email"
|
|
192
|
+
autocomplete="email"
|
|
193
|
+
class="w-full"
|
|
194
|
+
/>
|
|
195
|
+
</UFormField>
|
|
196
|
+
</UForm>
|
|
197
|
+
</template>
|
|
198
|
+
<template #footer>
|
|
199
|
+
<div class="flex w-full justify-end gap-2">
|
|
200
|
+
<UButton
|
|
201
|
+
color="neutral"
|
|
202
|
+
variant="outline"
|
|
203
|
+
:label="t('cancel', 'Cancel')"
|
|
204
|
+
@click="emailModalOpen = false"
|
|
205
|
+
/>
|
|
206
|
+
<UButton
|
|
207
|
+
type="submit"
|
|
208
|
+
form="change-email"
|
|
209
|
+
:loading="emailPending"
|
|
210
|
+
:label="t('email.change.title', 'Change email')"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
</template>
|
|
214
|
+
</UModal>
|
|
73
215
|
</UPageBody>
|
|
74
216
|
</UPage>
|
|
75
217
|
</UContainer>
|