@frigear-nu/oauth-server 0.0.10 → 0.1.1

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,184 +1,68 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "OAuthAdminPage" });
3
3
  const t = useModuleT("oauthServer");
4
- const confirmAction = useConfirmDialog();
5
- const { applications, status, error, create, update, remove, rotateSecret, revokeGrants } = useOAuthApplications();
6
- const emptyForm = () => ({
7
- name: "",
8
- websiteUrl: "",
9
- loginUrl: "",
10
- previewUrlPattern: "",
11
- description: "",
12
- icon: "i-lucide-globe",
13
- priority: 0,
14
- allowedRoles: [],
15
- tags: []
16
- });
17
- const formOpen = ref(false);
18
- const editingId = ref(null);
19
- const form = ref(emptyForm());
20
- const busy = ref(false);
21
- const failure = ref(null);
22
- const secret = ref(null);
4
+ const { applications, status, error } = useOAuthApplications();
5
+ const actions = useOAuthApplicationActions();
23
6
  const forbidden = computed(() => error.value?.statusCode === 401 || error.value?.statusCode === 403);
24
- function openCreate() {
25
- editingId.value = null;
26
- form.value = emptyForm();
27
- failure.value = null;
28
- formOpen.value = true;
29
- }
30
- function openEdit(application) {
31
- editingId.value = application.id;
32
- form.value = {
33
- name: application.name,
34
- websiteUrl: application.websiteUrl,
35
- loginUrl: application.loginUrl ?? "",
36
- previewUrlPattern: application.previewUrlPattern ?? "",
37
- description: application.description ?? "",
38
- icon: application.icon ?? "",
39
- priority: application.priority,
40
- allowedRoles: [...application.allowedRoles],
41
- tags: [...application.tags]
42
- };
43
- failure.value = null;
44
- formOpen.value = true;
45
- }
46
- const message = (err) => err.statusMessage || err.data?.message || "Something went wrong.";
47
- async function run(task) {
48
- busy.value = true;
49
- failure.value = null;
50
- try {
51
- await task();
52
- } catch (err) {
53
- failure.value = message(err);
54
- } finally {
55
- busy.value = false;
56
- }
57
- }
58
- function submit() {
59
- const value = form.value;
60
- const input = {
61
- name: value.name,
62
- websiteUrl: value.websiteUrl,
63
- loginUrl: value.loginUrl || null,
64
- previewUrlPattern: value.previewUrlPattern || null,
65
- description: value.description || null,
66
- icon: value.icon || null,
67
- priority: Number(value.priority) || 0,
68
- allowedRoles: value.allowedRoles,
69
- tags: value.tags
70
- };
71
- return run(async () => {
72
- if (editingId.value) {
73
- await update(editingId.value, input);
74
- } else {
75
- const created = await create(input);
76
- secret.value = { name: created.name, clientId: created.clientId, clientSecret: created.clientSecret };
77
- }
78
- formOpen.value = false;
79
- });
80
- }
81
- const rotate = (application) => run(async () => {
82
- if (!await confirmAction({
83
- title: t("admin.rotateTitle", "Replace the client secret of {name}?", { name: application.name }),
84
- description: t("admin.rotateDescription", "The old secret stops working and users are signed out."),
85
- confirmText: t("admin.newSecret", "New secret"),
86
- destructive: true
87
- })) return;
88
- const clientSecret = await rotateSecret(application.id);
89
- secret.value = { name: application.name, clientId: application.id, clientSecret };
90
- });
91
- const toggle = (application) => run(async () => {
92
- await update(application.id, { isActive: !application.isActive });
93
- });
94
- const signOutEveryone = (application) => run(async () => {
95
- if (await confirmAction({
96
- title: t("admin.signOutTitle", "Sign everyone out of {name}?", { name: application.name }),
97
- confirmText: t("admin.signOutEveryone", "Sign everyone out"),
98
- destructive: true
99
- })) await revokeGrants(application.id);
100
- });
101
- const destroy = (application) => run(async () => {
102
- if (await confirmAction({
103
- title: t("admin.deleteTitle", "Delete {name}?", { name: application.name }),
104
- description: t("admin.deleteDescription", "This cannot be undone."),
105
- confirmText: t("admin.delete", "Delete"),
106
- destructive: true
107
- })) await remove(application.id);
108
- });
109
- const columns = [
7
+ const columns = computed(() => [
110
8
  { accessorKey: "name", header: t("admin.application", "Application") },
111
9
  { id: "access", header: t("admin.access", "Access") },
112
- { id: "grants", header: t("admin.signedIn", "Signed in") },
113
- { id: "actions", header: "" }
114
- ];
115
- const rowActions = (application) => [[
116
- { label: t("admin.edit", "Edit"), icon: "i-lucide-pencil", onSelect: () => openEdit(application) },
117
- { label: application.isActive ? t("admin.disable", "Disable") : t("admin.enable", "Enable"), icon: application.isActive ? "i-lucide-pause" : "i-lucide-play", onSelect: () => toggle(application) },
118
- { label: t("admin.newSecret", "New secret"), icon: "i-lucide-key-round", onSelect: () => rotate(application) },
119
- { label: t("admin.signOutEveryone", "Sign everyone out"), icon: "i-lucide-log-out", onSelect: () => signOutEveryone(application) }
120
- ], [
121
- { label: t("admin.delete", "Delete"), icon: "i-lucide-trash", color: "error", onSelect: () => destroy(application) }
122
- ]];
123
- const copy = (value) => navigator.clipboard?.writeText(value);
10
+ { id: "grants", header: t("admin.signedIn", "Signed in"), meta: numericColumn },
11
+ { id: "actions", header: "", meta: { class: { th: "w-0", td: "w-0" } } }
12
+ ]);
124
13
  </script>
125
14
 
126
15
  <template>
127
- <AdminPage>
128
- <AdminPageHeader :title="t('admin.title', 'OAuth applications')">
129
- <template #actions>
16
+ <div class="flex flex-col gap-4">
17
+ <PageHeading
18
+ :title="t('admin.title', 'OAuth applications')"
19
+ :description="t('admin.description', 'The applications users can sign in to with their account.')"
20
+ >
21
+ <template
22
+ v-if="!forbidden"
23
+ #actions
24
+ >
130
25
  <UButton
131
- v-if="!forbidden"
132
26
  icon="i-lucide-plus"
133
- :label="t('admin.new', 'New application')"
134
- @click="openCreate"
27
+ :label="t('admin.new', 'Add application')"
28
+ @click="actions.create()"
135
29
  />
136
30
  </template>
137
- </AdminPageHeader>
31
+ </PageHeading>
138
32
 
139
- <UAlert
33
+ <UEmpty
140
34
  v-if="forbidden"
141
- color="error"
142
- variant="subtle"
143
- :title="t('admin.forbidden', 'You are not allowed to manage OAuth applications.')"
35
+ icon="i-lucide-lock"
36
+ :title="t('admin.forbiddenTitle', 'You are not allowed to manage OAuth applications')"
37
+ :description="t('admin.forbiddenDescription', 'Ask an administrator for access.')"
144
38
  />
145
- <template v-else>
146
- <UAlert
147
- v-if="failure && !formOpen"
148
- color="error"
149
- variant="subtle"
150
- :title="failure"
151
- />
152
-
153
- <AdminTable
39
+ <TableCard
40
+ v-else
41
+ :loading="status === 'pending' && !applications.length"
42
+ :refreshing="status === 'pending' && !!applications.length"
43
+ >
44
+ <UTable
154
45
  :data="applications"
155
46
  :columns="columns"
156
- :loading="status === 'pending'"
47
+ :ui="tableUi()"
48
+ @select="(_event, row2) => actions.edit(row2.original)"
157
49
  >
158
50
  <template #name-cell="{ row }">
159
- <div class="flex min-w-0 items-start gap-3">
160
- <UIcon
161
- :name="row.original.icon || 'i-lucide-globe'"
162
- class="mt-0.5 size-5 shrink-0"
163
- />
164
- <div class="min-w-0">
165
- <p class="flex items-center gap-2 font-medium">
166
- {{ row.original.name }}
167
- <UBadge
168
- v-if="!row.original.isActive"
169
- color="neutral"
170
- variant="subtle"
171
- :label="t('admin.disabled', 'Disabled')"
172
- />
173
- </p>
174
- <p class="truncate text-sm text-muted">
175
- {{ row.original.websiteUrl }}
176
- </p>
177
- <p class="truncate text-xs text-muted">
178
- {{ t("admin.callback", "Callback") }}: {{ row.original.callbackUrl }}
179
- </p>
180
- </div>
181
- </div>
51
+ <TableIdentity
52
+ :title="row.original.name"
53
+ :description="row.original.websiteUrl"
54
+ :avatar="{ icon: row.original.icon || 'i-lucide-globe' }"
55
+ >
56
+ <template #badges>
57
+ <UBadge
58
+ v-if="!row.original.isActive"
59
+ color="neutral"
60
+ variant="subtle"
61
+ size="sm"
62
+ :label="t('admin.disabled', 'Disabled')"
63
+ />
64
+ </template>
65
+ </TableIdentity>
182
66
  </template>
183
67
  <template #access-cell="{ row }">
184
68
  <div class="flex flex-wrap gap-1">
@@ -200,181 +84,21 @@ const copy = (value) => navigator.clipboard?.writeText(value);
200
84
  {{ row.original.activeGrants ?? 0 }}
201
85
  </template>
202
86
  <template #actions-cell="{ row }">
203
- <div class="flex justify-end">
204
- <UDropdownMenu :items="rowActions(row.original)">
205
- <UButton
206
- icon="i-lucide-ellipsis"
207
- color="neutral"
208
- variant="ghost"
209
- size="sm"
210
- :disabled="busy"
211
- :aria-label="t('admin.actions', 'Actions')"
212
- />
213
- </UDropdownMenu>
214
- </div>
87
+ <RowMenu
88
+ :items="actions.menu(row.original)"
89
+ :subject="row.original.name"
90
+ />
215
91
  </template>
216
92
  <template #empty>
217
- <AdminEmpty
93
+ <UEmpty
94
+ variant="naked"
218
95
  icon="i-lucide-app-window"
219
- :title="t('admin.empty', 'No applications yet.')"
96
+ :title="t('admin.emptyTitle', 'No applications yet')"
97
+ :description="t('admin.emptyDescription', 'Add an application to let users sign in to it with their account.')"
98
+ :actions="[{ label: t('admin.new', 'Add application'), icon: 'i-lucide-plus', onClick: () => actions.create() }]"
220
99
  />
221
100
  </template>
222
- </AdminTable>
223
- </template>
224
-
225
- <UModal
226
- v-model:open="formOpen"
227
- :title="editingId ? 'Edit application' : 'New application'"
228
- >
229
- <template #body>
230
- <form
231
- id="oauth-application-form"
232
- class="grid gap-4"
233
- @submit.prevent="submit"
234
- >
235
- <UFormField
236
- label="Name"
237
- required
238
- >
239
- <UInput
240
- v-model="form.name"
241
- class="w-full"
242
- required
243
- />
244
- </UFormField>
245
- <UFormField
246
- label="Website URL"
247
- description="The origin of the application, without a path. The callback is the website URL plus the configured callback path."
248
- required
249
- >
250
- <UInput
251
- v-model="form.websiteUrl"
252
- class="w-full"
253
- placeholder="https://app.example.com"
254
- required
255
- />
256
- </UFormField>
257
- <UFormField
258
- label="Login URL"
259
- description="Where the Open button goes. Defaults to the website URL."
260
- >
261
- <UInput
262
- v-model="form.loginUrl"
263
- class="w-full"
264
- />
265
- </UFormField>
266
- <UFormField
267
- label="Preview URL pattern"
268
- description="Optional, e.g. https://*.vercel.app, for preview deployments."
269
- >
270
- <UInput
271
- v-model="form.previewUrlPattern"
272
- class="w-full"
273
- />
274
- </UFormField>
275
- <UFormField
276
- label="Allowed roles"
277
- description="Leave empty to allow every signed-in user."
278
- >
279
- <UInputTags
280
- v-model="form.allowedRoles"
281
- class="w-full"
282
- />
283
- </UFormField>
284
- <UFormField label="Tags">
285
- <UInputTags
286
- v-model="form.tags"
287
- class="w-full"
288
- />
289
- </UFormField>
290
- <UFormField label="Description">
291
- <UTextarea
292
- v-model="form.description"
293
- class="w-full"
294
- />
295
- </UFormField>
296
- <div class="grid grid-cols-2 gap-4">
297
- <UFormField label="Icon">
298
- <UInput
299
- v-model="form.icon"
300
- class="w-full"
301
- />
302
- </UFormField>
303
- <UFormField label="Priority">
304
- <UInput
305
- v-model.number="form.priority"
306
- type="number"
307
- class="w-full"
308
- />
309
- </UFormField>
310
- </div>
311
- <UAlert
312
- v-if="failure"
313
- color="error"
314
- variant="subtle"
315
- :title="failure"
316
- />
317
- </form>
318
- </template>
319
- <template #footer>
320
- <div class="flex w-full justify-end gap-2">
321
- <UButton
322
- color="neutral"
323
- variant="outline"
324
- @click="formOpen = false"
325
- >
326
- Cancel
327
- </UButton>
328
- <UButton
329
- type="submit"
330
- form="oauth-application-form"
331
- :loading="busy"
332
- >
333
- {{ editingId ? "Save" : "Create" }}
334
- </UButton>
335
- </div>
336
- </template>
337
- </UModal>
338
-
339
- <UModal
340
- :open="!!secret"
341
- :title="secret ? `Credentials for ${secret.name}` : ''"
342
- description="Copy the client secret now. It is only shown once."
343
- @update:open="secret = null"
344
- >
345
- <template #body>
346
- <div
347
- v-if="secret"
348
- class="grid gap-3 text-sm"
349
- >
350
- <div>
351
- <div class="text-muted">
352
- Client ID
353
- </div>
354
- <code class="break-all">{{ secret.clientId }}</code>
355
- <UButton
356
- size="xs"
357
- variant="link"
358
- @click="copy(secret.clientId)"
359
- >
360
- Copy
361
- </UButton>
362
- </div>
363
- <div>
364
- <div class="text-muted">
365
- Client secret
366
- </div>
367
- <code class="break-all">{{ secret.clientSecret }}</code>
368
- <UButton
369
- size="xs"
370
- variant="link"
371
- @click="copy(secret.clientSecret)"
372
- >
373
- Copy
374
- </UButton>
375
- </div>
376
- </div>
377
- </template>
378
- </UModal>
379
- </AdminPage>
101
+ </UTable>
102
+ </TableCard>
103
+ </div>
380
104
  </template>
@@ -1,50 +1,38 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "OAuthApplicationsPage" });
3
+ const t = useModuleT("oauthServer");
3
4
  const { applications, status } = useMyApplications();
4
5
  </script>
5
6
 
6
7
  <template>
7
- <UContainer class="py-10">
8
- <h1 class="mb-6 text-2xl font-semibold">
9
- Applications
10
- </h1>
11
- <p
8
+ <div class="flex flex-col gap-4">
9
+ <UEmpty
12
10
  v-if="status !== 'pending' && !applications.length"
13
- class="text-muted"
11
+ icon="i-lucide-layout-grid"
12
+ :title="t('applications.emptyTitle', 'No applications yet')"
13
+ :description="t('applications.emptyDescription', 'There are no applications you can sign in to yet.')"
14
+ />
15
+ <div
16
+ v-else
17
+ class="grid gap-4 md:grid-cols-2"
14
18
  >
15
- There are no applications you can sign in to yet.
16
- </p>
17
- <div class="grid gap-4 md:grid-cols-2">
18
- <UCard
19
+ <UPageCard
19
20
  v-for="application in applications"
20
21
  :key="application.id"
22
+ variant="outline"
23
+ :title="application.name"
24
+ :description="application.description ?? application.websiteUrl"
25
+ :icon="application.icon || 'i-lucide-globe'"
21
26
  >
22
- <div class="flex items-start gap-3">
23
- <UIcon
24
- :name="application.icon || 'i-lucide-globe'"
25
- class="mt-1 size-6 shrink-0"
26
- />
27
- <div class="min-w-0 flex-1">
28
- <h2 class="font-medium">
29
- {{ application.name }}
30
- </h2>
31
- <p
32
- v-if="application.description"
33
- class="text-sm text-muted"
34
- >
35
- {{ application.description }}
36
- </p>
37
- </div>
38
- <UButton
39
- :to="application.loginUrl"
40
- external
41
- trailing-icon="i-lucide-arrow-up-right"
42
- size="sm"
43
- >
44
- Open
45
- </UButton>
46
- </div>
47
- </UCard>
27
+ <UButton
28
+ :to="application.loginUrl"
29
+ external
30
+ trailing-icon="i-lucide-arrow-up-right"
31
+ size="sm"
32
+ :label="t('applications.open', 'Open')"
33
+ :aria-label="t('applications.openApplication', 'Open {name}', { name: application.name })"
34
+ />
35
+ </UPageCard>
48
36
  </div>
49
- </UContainer>
37
+ </div>
50
38
  </template>
@@ -1,7 +1,9 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "OAuthAuthorizePage" });
3
+ const t = useModuleT("oauthServer");
3
4
  const route = useRoute();
4
5
  const signInPath = useAuthPages().login;
6
+ const showError = useErrorToast();
5
7
  const { data: consent, error } = await useFetch("/api/oauth-server/consent", {
6
8
  query: route.query,
7
9
  key: `oauth-server:consent:${route.fullPath}`
@@ -9,16 +11,17 @@ const { data: consent, error } = await useFetch("/api/oauth-server/consent", {
9
11
  if (error.value?.statusCode === 401) {
10
12
  await navigateTo(`${signInPath}?redirect=${encodeURIComponent(route.fullPath)}`);
11
13
  }
12
- const scopeLabels = {
13
- openid: "Verify your identity",
14
- profile: "Your name and profile picture",
15
- email: "Your email address"
14
+ const scopeLabel = (scope) => {
15
+ const known = {
16
+ openid: t("authorize.scope.openid", "Verify your identity"),
17
+ profile: t("authorize.scope.profile", "Your name and profile picture"),
18
+ email: t("authorize.scope.email", "Your email address")
19
+ };
20
+ return known[scope] ?? scope;
16
21
  };
17
22
  const pending = ref(null);
18
- const failure = ref(null);
19
23
  async function decide(approved) {
20
24
  pending.value = approved ? "approve" : "deny";
21
- failure.value = null;
22
25
  try {
23
26
  const { redirectUrl } = await $fetch("/oauth/authorize", {
24
27
  method: "POST",
@@ -26,83 +29,69 @@ async function decide(approved) {
26
29
  });
27
30
  await navigateTo(redirectUrl, { external: true });
28
31
  } catch (err) {
29
- failure.value = err.statusMessage || "Something went wrong. Please try again.";
32
+ showError(err, t("authorize.failed", "Could not complete the sign-in. Please try again."));
30
33
  pending.value = null;
31
34
  }
32
35
  }
33
36
  </script>
34
37
 
35
38
  <template>
36
- <UContainer class="py-10">
37
- <UCard
38
- v-if="consent"
39
- class="mx-auto max-w-md"
40
- >
41
- <template #header>
42
- <div class="flex items-center gap-3">
43
- <UIcon
44
- :name="consent.application.icon || 'i-lucide-globe'"
45
- class="size-8"
46
- />
47
- <div>
48
- <h1 class="text-lg font-semibold">
49
- {{ consent.application.name }}
50
- </h1>
51
- <p class="text-sm text-muted">
52
- {{ consent.application.websiteUrl }}
53
- </p>
54
- </div>
55
- </div>
56
- </template>
39
+ <AuthCard
40
+ v-if="consent"
41
+ :title="t('authorize.title', 'Sign in to {name}', { name: consent.application.name })"
42
+ >
43
+ <template #description>
44
+ {{ t("authorize.signedInAs", "Signed in as {name} ({email}).", { name: consent.user.name, email: consent.user.email }) }}
45
+ </template>
57
46
 
58
- <p class="mb-3 text-sm">
59
- Signed in as <strong>{{ consent.user.name }}</strong> ({{ consent.user.email }}).
60
- <strong>{{ consent.application.name }}</strong> wants to:
61
- </p>
62
- <ul class="mb-4 list-inside list-disc space-y-1 text-sm">
47
+ <div class="flex items-center gap-3">
48
+ <UIcon
49
+ :name="consent.application.icon || 'i-lucide-globe'"
50
+ class="size-8 shrink-0"
51
+ />
52
+ <div class="min-w-0">
53
+ <p class="text-highlighted font-medium">
54
+ {{ consent.application.name }}
55
+ </p>
56
+ <p class="truncate text-sm text-muted">
57
+ {{ consent.application.websiteUrl }}
58
+ </p>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="flex flex-col gap-2 text-sm">
63
+ <p>{{ t("authorize.wants", "{name} wants to:", { name: consent.application.name }) }}</p>
64
+ <ul class="list-inside list-disc space-y-1">
63
65
  <li
64
66
  v-for="scope in consent.scope"
65
67
  :key="scope"
66
68
  >
67
- {{ scopeLabels[scope] ?? scope }}
69
+ {{ scopeLabel(scope) }}
68
70
  </li>
69
71
  </ul>
70
- <UAlert
71
- v-if="failure"
72
- color="error"
73
- variant="subtle"
74
- :title="failure"
75
- />
72
+ </div>
76
73
 
77
- <template #footer>
78
- <div class="flex justify-end gap-2">
79
- <UButton
80
- color="neutral"
81
- variant="outline"
82
- :loading="pending === 'deny'"
83
- :disabled="!!pending"
84
- @click="decide(false)"
85
- >
86
- Deny
87
- </UButton>
88
- <UButton
89
- :loading="pending === 'approve'"
90
- :disabled="!!pending"
91
- @click="decide(true)"
92
- >
93
- Allow
94
- </UButton>
95
- </div>
96
- </template>
97
- </UCard>
74
+ <div class="flex justify-end gap-2">
75
+ <UButton
76
+ color="neutral"
77
+ variant="outline"
78
+ :label="t('authorize.deny', 'Deny')"
79
+ :loading="pending === 'deny'"
80
+ :disabled="!!pending"
81
+ @click="decide(false)"
82
+ />
83
+ <UButton
84
+ :label="t('authorize.allow', 'Allow')"
85
+ :loading="pending === 'approve'"
86
+ :disabled="!!pending"
87
+ @click="decide(true)"
88
+ />
89
+ </div>
90
+ </AuthCard>
98
91
 
99
- <UAlert
100
- v-else
101
- class="mx-auto max-w-md"
102
- color="error"
103
- variant="subtle"
104
- title="This sign-in request cannot be completed"
105
- :description="error?.statusMessage || 'The request is invalid.'"
106
- />
107
- </UContainer>
92
+ <AuthCard
93
+ v-else
94
+ :title="t('authorize.invalidTitle', 'This sign-in request cannot be completed')"
95
+ :description="error?.statusMessage || t('authorize.invalidDescription', 'The request is invalid.')"
96
+ />
108
97
  </template>