@nuxtagentic/identity 0.1.1 → 0.1.2
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 +1 -6
- package/package.json +4 -4
- package/runtime/components/BrandMark.vue +13 -4
- package/runtime/components/GithubAuthPanel.vue +2 -63
- package/runtime/components/UserAvatar.vue +1 -1
- package/runtime/components/WorkspaceSwitcher.vue +11 -19
- package/runtime/components/settings/ImageField.vue +1 -1
- package/runtime/components/settings/Provisioning.vue +7 -5
- package/runtime/components/settings/SettingsOrganizations.vue +0 -1
- package/runtime/composables/useOrganizationAccess.ts +4 -4
- package/runtime/composables/useWorkspaces.ts +1 -1
- package/runtime/layouts/default.vue +2 -2
- package/runtime/pages/sign-in.vue +1 -1
- package/runtime/components/CloudMascot.vue +0 -34
- package/runtime/components/CloudconfigLogo.vue +0 -34
- package/runtime/components/settings/SingleSignOn.vue +0 -396
package/README.md
CHANGED
|
@@ -34,12 +34,7 @@ Identity image uploads share the existing `avatar` storage model and retain thei
|
|
|
34
34
|
uploaded asset when saving fails, so retries do not upload the same file again.
|
|
35
35
|
`useProfileNamePreview` keeps live name previews within Identity's state ownership.
|
|
36
36
|
|
|
37
|
-
##
|
|
38
|
-
|
|
39
|
-
With `auth.sso: true` (and `@better-auth/sso` installed), **Settings → Organization →
|
|
40
|
-
Single sign-on** lets owners and admins register an OIDC or SAML identity provider and
|
|
41
|
-
verify their email domain with a DNS TXT record. People on that domain choose **Use single
|
|
42
|
-
sign-on** on the sign-in page and join the organization.
|
|
37
|
+
## Directory provisioning
|
|
43
38
|
|
|
44
39
|
With `auth.scim: true`, **Directory provisioning** mints a SCIM token and shows the base
|
|
45
40
|
URL to paste into Okta, Entra ID or Google Workspace. Provisioned people join the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxtagentic/identity",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"module.ts",
|
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@better-auth/passkey": "^1.7.7",
|
|
24
|
-
"@cloudconfig/core": "0.2.0-beta.
|
|
25
|
-
"@cloudconfig/nuxt": "0.2.0-beta.
|
|
24
|
+
"@cloudconfig/core": "0.2.0-beta.6",
|
|
25
|
+
"@cloudconfig/nuxt": "0.2.0-beta.6",
|
|
26
26
|
"@iconify-json/lucide": "^1.2.132",
|
|
27
27
|
"@iconify-json/simple-icons": "^1.2.96",
|
|
28
28
|
"@nuxt/kit": "4.6.1",
|
|
29
29
|
"@nuxt/ui": "^4.11.1",
|
|
30
30
|
"@vueuse/core": "^14.4.0",
|
|
31
31
|
"better-auth": "^1.7.7",
|
|
32
|
-
"@nuxtagentic/kit": "0.1.
|
|
32
|
+
"@nuxtagentic/kit": "0.1.2"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"nuxt": "^4.6.1",
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
2
|
+
<svg
|
|
3
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
4
|
+
width="24"
|
|
5
|
+
height="24"
|
|
6
|
+
viewBox="48 48 160 160"
|
|
7
|
+
fill="none"
|
|
4
8
|
aria-hidden="true"
|
|
9
|
+
class="shrink-0"
|
|
5
10
|
>
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
<path
|
|
12
|
+
fill="#00DC82"
|
|
13
|
+
fill-rule="evenodd"
|
|
14
|
+
d="m95.847 148.193l52.206-52.594A31.86 31.86 0 0 1 144 80c0-17.673 14.327-32 32-32s32 14.327 32 32s-14.327 32-32 32a31.85 31.85 0 0 1-16.89-4.815l-52.068 51.699A31.85 31.85 0 0 1 112 176c0 17.673-14.327 32-32 32s-32-14.327-32-32s14.327-32 32-32a31.85 31.85 0 0 1 15.847 4.193M176 96c8.837 0 16-7.163 16-16s-7.163-16-16-16s-16 7.163-16 16s7.163 16 16 16m-96 96c8.837 0 16-7.163 16-16s-7.163-16-16-16s-16 7.163-16 16s7.163 16 16 16"
|
|
15
|
+
/>
|
|
16
|
+
</svg>
|
|
8
17
|
</template>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useCloudConfigAuth, useRuntimeConfig } from '#imports'
|
|
3
3
|
import { ref } from 'vue'
|
|
4
|
-
import { useSsoClient } from '../composables/useOrganizationAccess'
|
|
5
4
|
|
|
6
5
|
const { title, description, label } = defineProps<{
|
|
7
6
|
title: string
|
|
@@ -14,36 +13,8 @@ const isGoogleEnabled = (useRuntimeConfig().public.authProviders ?? ['github', '
|
|
|
14
13
|
'google',
|
|
15
14
|
)
|
|
16
15
|
const isBusy = ref(false)
|
|
17
|
-
const provider = ref<'google' | 'github'
|
|
16
|
+
const provider = ref<'google' | 'github'>('google')
|
|
18
17
|
const error = ref('')
|
|
19
|
-
const sso = useSsoClient()
|
|
20
|
-
const showSso = ref(false)
|
|
21
|
-
const email = ref('')
|
|
22
|
-
|
|
23
|
-
/** Enterprise sign-in: the email domain picks the organization's identity provider. */
|
|
24
|
-
async function continueWithSso() {
|
|
25
|
-
if (isBusy.value || !email.value.includes('@')) {
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
isBusy.value = true
|
|
29
|
-
provider.value = 'sso'
|
|
30
|
-
error.value = ''
|
|
31
|
-
try {
|
|
32
|
-
window.location.assign(
|
|
33
|
-
await sso.signIn({
|
|
34
|
-
email: email.value.trim(),
|
|
35
|
-
callbackURL: '/dashboard',
|
|
36
|
-
errorCallbackURL: '/sign-in',
|
|
37
|
-
}),
|
|
38
|
-
)
|
|
39
|
-
} catch (cause) {
|
|
40
|
-
error.value =
|
|
41
|
-
cause instanceof Error && !/not found/i.test(cause.message)
|
|
42
|
-
? cause.message
|
|
43
|
-
: 'No single sign-on is set up for that email domain.'
|
|
44
|
-
isBusy.value = false
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
18
|
|
|
48
19
|
async function continueWithProvider(value: 'google' | 'github') {
|
|
49
20
|
if (isBusy.value) {
|
|
@@ -69,7 +40,7 @@ async function continueWithProvider(value: 'google' | 'github') {
|
|
|
69
40
|
|
|
70
41
|
<template>
|
|
71
42
|
<section class="flex w-full flex-col items-center text-center">
|
|
72
|
-
<
|
|
43
|
+
<BrandMark class="size-20" />
|
|
73
44
|
<h1
|
|
74
45
|
class="mt-7 mb-2 text-[32px] leading-[1.15] font-normal tracking-[-0.03em] text-highlighted"
|
|
75
46
|
>
|
|
@@ -98,38 +69,6 @@ async function continueWithProvider(value: 'google' | 'github') {
|
|
|
98
69
|
:disabled="isBusy"
|
|
99
70
|
@click="continueWithProvider('github')"
|
|
100
71
|
/>
|
|
101
|
-
<form
|
|
102
|
-
v-if="showSso"
|
|
103
|
-
class="mt-3 flex w-full gap-2"
|
|
104
|
-
@submit.prevent="continueWithSso"
|
|
105
|
-
>
|
|
106
|
-
<UInput
|
|
107
|
-
v-model="email"
|
|
108
|
-
type="email"
|
|
109
|
-
required
|
|
110
|
-
autocomplete="email"
|
|
111
|
-
placeholder="you@company.com"
|
|
112
|
-
aria-label="Work email"
|
|
113
|
-
class="min-w-0 flex-1"
|
|
114
|
-
:ui="{ base: 'h-10' }"
|
|
115
|
-
/>
|
|
116
|
-
<UButton
|
|
117
|
-
type="submit"
|
|
118
|
-
label="Continue"
|
|
119
|
-
color="neutral"
|
|
120
|
-
:loading="isBusy && provider === 'sso'"
|
|
121
|
-
:disabled="isBusy"
|
|
122
|
-
class="h-10 px-4"
|
|
123
|
-
/>
|
|
124
|
-
</form>
|
|
125
|
-
<UButton
|
|
126
|
-
v-else
|
|
127
|
-
label="Use single sign-on"
|
|
128
|
-
color="neutral"
|
|
129
|
-
variant="link"
|
|
130
|
-
class="mt-3 text-[13px]"
|
|
131
|
-
@click="showSso = true"
|
|
132
|
-
/>
|
|
133
72
|
<p
|
|
134
73
|
v-if="error"
|
|
135
74
|
role="alert"
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
import { ref, watch } from 'vue'
|
|
3
3
|
import { useWorkspaces } from '../composables/useWorkspaces'
|
|
4
4
|
|
|
5
|
-
import type { Organization } from '@cloudconfig/core/client'
|
|
6
|
-
|
|
7
5
|
const { collapsed = false } = defineProps<{ collapsed?: boolean }>()
|
|
8
6
|
|
|
9
7
|
const isOpen = ref(false)
|
|
@@ -11,14 +9,6 @@ const { activeOrganization, menu, switching, error, refresh } = await useWorkspa
|
|
|
11
9
|
isOpen.value = false
|
|
12
10
|
})
|
|
13
11
|
|
|
14
|
-
function shouldShowDefaultLogo(organization: Organization | null) {
|
|
15
|
-
return (
|
|
16
|
-
!organization ||
|
|
17
|
-
(!organization.logo &&
|
|
18
|
-
(organization.name.toLowerCase() === 'cloudconfig' || organization.slug === 'cloudconfig'))
|
|
19
|
-
)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
12
|
watch(isOpen, (value) => {
|
|
23
13
|
if (value) {
|
|
24
14
|
void refresh()
|
|
@@ -57,7 +47,7 @@ watch(
|
|
|
57
47
|
size="md"
|
|
58
48
|
:square="collapsed"
|
|
59
49
|
:trailing-icon="collapsed ? undefined : 'i-lucide-chevron-down'"
|
|
60
|
-
:label="collapsed ? undefined : (activeOrganization?.name ?? '
|
|
50
|
+
:label="collapsed ? undefined : (activeOrganization?.name ?? 'Personal workspace')"
|
|
61
51
|
class="h-10 w-auto max-w-full min-w-0 gap-2.5 rounded-lg py-0 pr-2.5 pl-2 text-[15px] leading-[normal] font-medium text-highlighted duration-160 hover:bg-(--gray-400) data-[state=isOpen]:bg-(--gray-400) motion-reduce:transition-none motion-reduce:active:scale-100"
|
|
62
52
|
:ui="{
|
|
63
53
|
label: 'min-w-0 truncate',
|
|
@@ -67,15 +57,16 @@ watch(
|
|
|
67
57
|
aria-label="Open organization menu"
|
|
68
58
|
>
|
|
69
59
|
<template #leading>
|
|
70
|
-
<
|
|
71
|
-
v-if="
|
|
72
|
-
|
|
60
|
+
<UIcon
|
|
61
|
+
v-if="!activeOrganization"
|
|
62
|
+
name="i-lucide-user-round"
|
|
63
|
+
class="size-4 text-muted"
|
|
73
64
|
/>
|
|
74
65
|
<UAvatar
|
|
75
66
|
v-else
|
|
76
67
|
:src="activeOrganization?.logo ?? undefined"
|
|
77
|
-
:alt="activeOrganization?.name
|
|
78
|
-
:text="
|
|
68
|
+
:alt="activeOrganization?.name"
|
|
69
|
+
:text="activeOrganization?.name.charAt(0).toUpperCase()"
|
|
79
70
|
size="xs"
|
|
80
71
|
:ui="{
|
|
81
72
|
root: 'rounded-md bg-(--gray-500)',
|
|
@@ -87,9 +78,10 @@ watch(
|
|
|
87
78
|
</UButton>
|
|
88
79
|
|
|
89
80
|
<template #workspace-leading="{ item }">
|
|
90
|
-
<
|
|
91
|
-
v-if="
|
|
92
|
-
|
|
81
|
+
<UIcon
|
|
82
|
+
v-if="!item.workspace"
|
|
83
|
+
name="i-lucide-user-round"
|
|
84
|
+
class="size-4 text-muted"
|
|
93
85
|
/>
|
|
94
86
|
<UAvatar
|
|
95
87
|
v-else
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { onMounted, ref, watch } from 'vue'
|
|
3
3
|
import type { ScimToken } from '@cloudconfig/core/client'
|
|
4
|
-
import { useOrganizationAdmin,
|
|
4
|
+
import { useOrganizationAdmin, useScimClient } from '../../composables/useOrganizationAccess'
|
|
5
5
|
import { fieldUi } from '../../settings-ui'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
defineOptions({ name: 'SettingsProvisioning' })
|
|
8
|
+
|
|
9
|
+
const scim = useScimClient()
|
|
8
10
|
const { organization, isAdmin } = useOrganizationAdmin()
|
|
9
11
|
const tokens = ref<ScimToken[]>([])
|
|
10
12
|
const baseUrl = ref('')
|
|
@@ -18,7 +20,7 @@ async function load() {
|
|
|
18
20
|
return
|
|
19
21
|
}
|
|
20
22
|
try {
|
|
21
|
-
const result = await
|
|
23
|
+
const result = await scim.list()
|
|
22
24
|
tokens.value = result.tokens
|
|
23
25
|
baseUrl.value = result.baseUrl
|
|
24
26
|
} catch (cause) {
|
|
@@ -50,7 +52,7 @@ async function run(work: () => Promise<unknown>) {
|
|
|
50
52
|
|
|
51
53
|
const create = () =>
|
|
52
54
|
run(async () => {
|
|
53
|
-
const secret = await
|
|
55
|
+
const secret = await scim.create('Directory')
|
|
54
56
|
created.value = secret.token
|
|
55
57
|
baseUrl.value = secret.baseUrl
|
|
56
58
|
await load()
|
|
@@ -58,7 +60,7 @@ const create = () =>
|
|
|
58
60
|
|
|
59
61
|
const revoke = (token: ScimToken) =>
|
|
60
62
|
run(async () => {
|
|
61
|
-
await
|
|
63
|
+
await scim.revoke(token.uid)
|
|
62
64
|
await load()
|
|
63
65
|
})
|
|
64
66
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useCloudConfigOrganizations, useRequestEvent, useRuntimeConfig, useState } from '#imports'
|
|
2
2
|
import { computed, ref, watch } from 'vue'
|
|
3
|
-
import {
|
|
3
|
+
import { createScimClient, type Organization } from '@cloudconfig/core/client'
|
|
4
4
|
import { useCurrentUser } from './useCurrentUser'
|
|
5
5
|
|
|
6
|
-
/** The
|
|
7
|
-
export function
|
|
6
|
+
/** The SCIM client, pointed at the auth mount and SSR-safe. */
|
|
7
|
+
export function useScimClient() {
|
|
8
8
|
const config = useRuntimeConfig().public.cloudconfig as { authBasePath?: string } | undefined
|
|
9
9
|
const event = useRequestEvent()
|
|
10
|
-
return
|
|
10
|
+
return createScimClient({
|
|
11
11
|
baseUrl: config?.authBasePath ?? '/api/auth',
|
|
12
12
|
// During SSR the request event's fetch keeps the caller's cookies.
|
|
13
13
|
fetch: (input, init) => (event ? event.fetch(String(input), init) : fetch(input, init)),
|
|
@@ -74,7 +74,7 @@ export async function useWorkspaces(onSelected: () => void = () => {}) {
|
|
|
74
74
|
|
|
75
75
|
function workspaceItem(workspace: Organization | null): WorkspaceMenuItem {
|
|
76
76
|
return {
|
|
77
|
-
label: workspace?.name ?? '
|
|
77
|
+
label: workspace?.name ?? 'Personal workspace',
|
|
78
78
|
workspace,
|
|
79
79
|
slot: 'workspace',
|
|
80
80
|
type: 'checkbox',
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
to="/"
|
|
8
8
|
class="flex items-center gap-2 text-sm font-medium text-muted transition-colors duration-140 hover:text-highlighted focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary motion-reduce:transition-none"
|
|
9
9
|
>
|
|
10
|
-
<BrandMark />
|
|
11
|
-
<span
|
|
10
|
+
<BrandMark class="size-6" />
|
|
11
|
+
<span>Nuxt Agentic</span>
|
|
12
12
|
</NuxtLink>
|
|
13
13
|
</header>
|
|
14
14
|
<main class="flex flex-1 items-center justify-center px-6 pt-6 pb-24">
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<svg
|
|
3
|
-
viewBox="0 0 80 72"
|
|
4
|
-
fill="none"
|
|
5
|
-
shape-rendering="crispEdges"
|
|
6
|
-
aria-hidden="true"
|
|
7
|
-
class="shrink-0"
|
|
8
|
-
>
|
|
9
|
-
<path
|
|
10
|
-
d="M26 8h24v4h8v8h4v8h8v4h4v20h-4v4H10v-4H6V36h4v-4h12V20h4Z"
|
|
11
|
-
class="fill-(--ui-text-highlighted)"
|
|
12
|
-
/>
|
|
13
|
-
<path
|
|
14
|
-
d="M30 12h16v4h8v8h4v8h10v4h2v12h-4v4H14v-4h-4V38h4v-2h12V24h4Z"
|
|
15
|
-
class="fill-(--ui-bg)"
|
|
16
|
-
/>
|
|
17
|
-
<path
|
|
18
|
-
d="M14 44h12v4h36v-4h8v4h-4v4H14Z"
|
|
19
|
-
class="fill-(--gray-300)"
|
|
20
|
-
/>
|
|
21
|
-
<path
|
|
22
|
-
d="M32 30h4v8h-4zm16 0h4v8h-4zM38 42h10v4H38z"
|
|
23
|
-
class="fill-(--ui-text-highlighted)"
|
|
24
|
-
/>
|
|
25
|
-
<path
|
|
26
|
-
d="M60 10h4v4h4v4h-4v4h-4v-4h-4v-4h4Z"
|
|
27
|
-
class="fill-[#f97316]"
|
|
28
|
-
/>
|
|
29
|
-
<path
|
|
30
|
-
d="M28 64h24v4H28zM22 66h36v2H22z"
|
|
31
|
-
class="fill-(--gray-600)"
|
|
32
|
-
/>
|
|
33
|
-
</svg>
|
|
34
|
-
</template>
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<svg
|
|
3
|
-
viewBox="0 0 80 72"
|
|
4
|
-
fill="none"
|
|
5
|
-
shape-rendering="crispEdges"
|
|
6
|
-
aria-hidden="true"
|
|
7
|
-
class="shrink-0"
|
|
8
|
-
>
|
|
9
|
-
<path
|
|
10
|
-
d="M26 8h24v4h8v8h4v8h8v4h4v20h-4v4H10v-4H6V36h4v-4h12V20h4Z"
|
|
11
|
-
class="fill-(--ui-text-highlighted)"
|
|
12
|
-
/>
|
|
13
|
-
<path
|
|
14
|
-
d="M30 12h16v4h8v8h4v8h10v4h2v12h-4v4H14v-4h-4V38h4v-2h12V24h4Z"
|
|
15
|
-
class="fill-(--ui-bg)"
|
|
16
|
-
/>
|
|
17
|
-
<path
|
|
18
|
-
d="M14 44h12v4h36v-4h8v4h-4v4H14Z"
|
|
19
|
-
class="fill-(--ui-bg-accented)"
|
|
20
|
-
/>
|
|
21
|
-
<path
|
|
22
|
-
d="M32 30h4v8h-4zm16 0h4v8h-4zM38 42h10v4H38z"
|
|
23
|
-
class="fill-(--ui-text-highlighted)"
|
|
24
|
-
/>
|
|
25
|
-
<path
|
|
26
|
-
d="M60 10h4v4h4v4h-4v4h-4v-4h-4v-4h4Z"
|
|
27
|
-
class="fill-orange-500"
|
|
28
|
-
/>
|
|
29
|
-
<path
|
|
30
|
-
d="M28 64h24v4H28zM22 66h36v2H22z"
|
|
31
|
-
class="fill-(--ui-border-accented)"
|
|
32
|
-
/>
|
|
33
|
-
</svg>
|
|
34
|
-
</template>
|
|
@@ -1,396 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { useRuntimeConfig } from '#imports'
|
|
3
|
-
import { computed, onMounted, ref } from 'vue'
|
|
4
|
-
import type { SsoProvider } from '@cloudconfig/core/client'
|
|
5
|
-
import { useOrganizationAdmin, useSsoClient } from '../../composables/useOrganizationAccess'
|
|
6
|
-
import { fieldUi, formFieldUi } from '../../settings-ui'
|
|
7
|
-
|
|
8
|
-
const sso = useSsoClient()
|
|
9
|
-
const { organization, isAdmin } = useOrganizationAdmin()
|
|
10
|
-
const authBase = String(
|
|
11
|
-
(useRuntimeConfig().public.cloudconfig as { authBasePath?: string } | undefined)?.authBasePath ??
|
|
12
|
-
'/api/auth',
|
|
13
|
-
)
|
|
14
|
-
const providers = ref<SsoProvider[]>([])
|
|
15
|
-
const available = ref(true)
|
|
16
|
-
const isBusy = ref(false)
|
|
17
|
-
const error = ref('')
|
|
18
|
-
const adding = ref(false)
|
|
19
|
-
const protocol = ref<'oidc' | 'saml'>('oidc')
|
|
20
|
-
const form = ref({
|
|
21
|
-
providerId: '',
|
|
22
|
-
issuer: '',
|
|
23
|
-
domain: '',
|
|
24
|
-
clientId: '',
|
|
25
|
-
clientSecret: '',
|
|
26
|
-
entryPoint: '',
|
|
27
|
-
cert: '',
|
|
28
|
-
metadata: '',
|
|
29
|
-
})
|
|
30
|
-
const verification = ref<{ providerId: string; name: string; value: string }>()
|
|
31
|
-
const origin = computed(() => (import.meta.client ? location.origin : ''))
|
|
32
|
-
const own = computed(() =>
|
|
33
|
-
providers.value.filter(
|
|
34
|
-
(provider) => String(provider.organizationId) === String(organization.value?.id),
|
|
35
|
-
),
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
async function load() {
|
|
39
|
-
try {
|
|
40
|
-
providers.value = await sso.providers()
|
|
41
|
-
} catch (cause) {
|
|
42
|
-
const status = (cause as { status?: number }).status
|
|
43
|
-
// Without the SSO plugin the routes do not exist; hide the section.
|
|
44
|
-
if (status === 404) {
|
|
45
|
-
available.value = false
|
|
46
|
-
} else {
|
|
47
|
-
error.value = cause instanceof Error ? cause.message : 'Could not load identity providers.'
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
onMounted(load)
|
|
52
|
-
|
|
53
|
-
async function run(work: () => Promise<unknown>) {
|
|
54
|
-
if (isBusy.value) {
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
isBusy.value = true
|
|
58
|
-
error.value = ''
|
|
59
|
-
try {
|
|
60
|
-
await work()
|
|
61
|
-
} catch (cause) {
|
|
62
|
-
error.value = cause instanceof Error ? cause.message : 'Something went wrong.'
|
|
63
|
-
} finally {
|
|
64
|
-
isBusy.value = false
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function slugify(value: string) {
|
|
69
|
-
return value
|
|
70
|
-
.toLowerCase()
|
|
71
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
72
|
-
.replace(/(^-|-$)/g, '')
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function register() {
|
|
76
|
-
const organizationId = Number(organization.value?.id)
|
|
77
|
-
if (!organizationId) {
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
await run(async () => {
|
|
81
|
-
const providerId = slugify(
|
|
82
|
-
form.value.providerId || `${organization.value?.slug}-${protocol.value}`,
|
|
83
|
-
)
|
|
84
|
-
if (protocol.value === 'oidc') {
|
|
85
|
-
await sso.registerOidc({
|
|
86
|
-
providerId,
|
|
87
|
-
issuer: form.value.issuer.trim(),
|
|
88
|
-
domain: form.value.domain.trim().toLowerCase(),
|
|
89
|
-
clientId: form.value.clientId.trim(),
|
|
90
|
-
clientSecret: form.value.clientSecret.trim(),
|
|
91
|
-
organizationId,
|
|
92
|
-
})
|
|
93
|
-
} else {
|
|
94
|
-
await sso.registerSaml({
|
|
95
|
-
providerId,
|
|
96
|
-
issuer: form.value.issuer.trim(),
|
|
97
|
-
domain: form.value.domain.trim().toLowerCase(),
|
|
98
|
-
entryPoint: form.value.entryPoint.trim(),
|
|
99
|
-
organizationId,
|
|
100
|
-
...(form.value.metadata.trim() ? { idpMetadata: form.value.metadata.trim() } : {}),
|
|
101
|
-
...(form.value.cert.trim() ? { cert: form.value.cert.trim() } : {}),
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
adding.value = false
|
|
105
|
-
form.value = {
|
|
106
|
-
providerId: '',
|
|
107
|
-
issuer: '',
|
|
108
|
-
domain: '',
|
|
109
|
-
clientId: '',
|
|
110
|
-
clientSecret: '',
|
|
111
|
-
entryPoint: '',
|
|
112
|
-
cert: '',
|
|
113
|
-
metadata: '',
|
|
114
|
-
}
|
|
115
|
-
await load()
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function requestVerification(provider: SsoProvider) {
|
|
120
|
-
await run(async () => {
|
|
121
|
-
const domain = provider.domain.split(',')[0]!.trim()
|
|
122
|
-
verification.value = {
|
|
123
|
-
providerId: provider.providerId,
|
|
124
|
-
...(await sso.requestDomainVerification(provider.providerId, domain)),
|
|
125
|
-
}
|
|
126
|
-
})
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function verify(provider: SsoProvider) {
|
|
130
|
-
await run(async () => {
|
|
131
|
-
await sso.verifyDomain(provider.providerId)
|
|
132
|
-
verification.value = undefined
|
|
133
|
-
await load()
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function remove(provider: SsoProvider) {
|
|
138
|
-
await run(async () => {
|
|
139
|
-
await sso.remove(provider.providerId)
|
|
140
|
-
await load()
|
|
141
|
-
})
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function copy(value: string) {
|
|
145
|
-
error.value = ''
|
|
146
|
-
try {
|
|
147
|
-
await navigator.clipboard.writeText(value)
|
|
148
|
-
} catch (cause) {
|
|
149
|
-
error.value = cause instanceof Error ? cause.message : 'Could not copy to the clipboard.'
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
</script>
|
|
153
|
-
|
|
154
|
-
<template>
|
|
155
|
-
<SettingsSection
|
|
156
|
-
v-if="available && organization"
|
|
157
|
-
title="Single sign-on"
|
|
158
|
-
description="People on your verified email domain sign in through your identity provider and join this organization."
|
|
159
|
-
>
|
|
160
|
-
<p
|
|
161
|
-
v-if="error"
|
|
162
|
-
role="alert"
|
|
163
|
-
class="text-[13px] text-error"
|
|
164
|
-
>
|
|
165
|
-
{{ error }}
|
|
166
|
-
</p>
|
|
167
|
-
<p
|
|
168
|
-
v-if="!isAdmin"
|
|
169
|
-
class="text-sm text-muted"
|
|
170
|
-
>
|
|
171
|
-
Organization owners and admins set up single sign-on.
|
|
172
|
-
</p>
|
|
173
|
-
<template v-else>
|
|
174
|
-
<div
|
|
175
|
-
v-for="provider in own"
|
|
176
|
-
:key="provider.providerId"
|
|
177
|
-
class="grid gap-2 rounded-md border border-default p-3"
|
|
178
|
-
>
|
|
179
|
-
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
180
|
-
<div class="min-w-0">
|
|
181
|
-
<p class="truncate text-sm text-highlighted">{{ provider.issuer }}</p>
|
|
182
|
-
<p class="mt-0.5 truncate text-[13px] text-muted">
|
|
183
|
-
{{ provider.domain }} ·
|
|
184
|
-
{{ provider.domainVerified ? 'domain verified' : 'domain not verified yet' }}
|
|
185
|
-
</p>
|
|
186
|
-
</div>
|
|
187
|
-
<div class="flex shrink-0 gap-2">
|
|
188
|
-
<UButton
|
|
189
|
-
v-if="!provider.domainVerified"
|
|
190
|
-
label="Verify domain"
|
|
191
|
-
color="neutral"
|
|
192
|
-
variant="outline"
|
|
193
|
-
:disabled="isBusy"
|
|
194
|
-
class="h-9 rounded-md px-3 text-sm font-normal"
|
|
195
|
-
@click="requestVerification(provider)"
|
|
196
|
-
/>
|
|
197
|
-
<UButton
|
|
198
|
-
label="Remove"
|
|
199
|
-
color="neutral"
|
|
200
|
-
variant="ghost"
|
|
201
|
-
:disabled="isBusy"
|
|
202
|
-
class="h-9 rounded-md px-3 text-sm font-normal"
|
|
203
|
-
@click="remove(provider)"
|
|
204
|
-
/>
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
207
|
-
<div
|
|
208
|
-
v-if="verification?.providerId === provider.providerId"
|
|
209
|
-
class="grid gap-2"
|
|
210
|
-
>
|
|
211
|
-
<p class="text-[13px] text-muted">
|
|
212
|
-
Add this TXT record at your DNS host, then verify. Sign-in through this provider starts
|
|
213
|
-
working once the domain is verified.
|
|
214
|
-
</p>
|
|
215
|
-
<UInput
|
|
216
|
-
:model-value="verification.name"
|
|
217
|
-
readonly
|
|
218
|
-
aria-label="TXT record name"
|
|
219
|
-
:ui="fieldUi"
|
|
220
|
-
/>
|
|
221
|
-
<UInput
|
|
222
|
-
:model-value="verification.value"
|
|
223
|
-
readonly
|
|
224
|
-
aria-label="TXT record value"
|
|
225
|
-
:ui="fieldUi"
|
|
226
|
-
/>
|
|
227
|
-
<div class="flex gap-2">
|
|
228
|
-
<UButton
|
|
229
|
-
label="Copy value"
|
|
230
|
-
color="neutral"
|
|
231
|
-
variant="outline"
|
|
232
|
-
size="sm"
|
|
233
|
-
@click="copy(verification.value)"
|
|
234
|
-
/>
|
|
235
|
-
<UButton
|
|
236
|
-
label="Verify"
|
|
237
|
-
color="neutral"
|
|
238
|
-
size="sm"
|
|
239
|
-
:loading="isBusy"
|
|
240
|
-
@click="verify(provider)"
|
|
241
|
-
/>
|
|
242
|
-
</div>
|
|
243
|
-
</div>
|
|
244
|
-
</div>
|
|
245
|
-
<form
|
|
246
|
-
v-if="adding"
|
|
247
|
-
class="grid gap-4"
|
|
248
|
-
@submit.prevent="register"
|
|
249
|
-
>
|
|
250
|
-
<div
|
|
251
|
-
class="grid grid-cols-2 gap-1 rounded-lg border border-default p-0.5"
|
|
252
|
-
role="radiogroup"
|
|
253
|
-
aria-label="Protocol"
|
|
254
|
-
>
|
|
255
|
-
<UButton
|
|
256
|
-
v-for="value in ['oidc', 'saml'] as const"
|
|
257
|
-
:key="value"
|
|
258
|
-
:label="value === 'oidc' ? 'OpenID Connect' : 'SAML 2.0'"
|
|
259
|
-
color="neutral"
|
|
260
|
-
:variant="protocol === value ? 'soft' : 'ghost'"
|
|
261
|
-
role="radio"
|
|
262
|
-
:aria-checked="protocol === value"
|
|
263
|
-
class="h-8 justify-center rounded-md text-[13px]"
|
|
264
|
-
@click="protocol = value"
|
|
265
|
-
/>
|
|
266
|
-
</div>
|
|
267
|
-
<UFormField
|
|
268
|
-
label="Email domain"
|
|
269
|
-
description="Comma-separate several domains served by the same provider."
|
|
270
|
-
:ui="formFieldUi"
|
|
271
|
-
>
|
|
272
|
-
<UInput
|
|
273
|
-
v-model="form.domain"
|
|
274
|
-
required
|
|
275
|
-
placeholder="acme.com"
|
|
276
|
-
:ui="fieldUi"
|
|
277
|
-
/>
|
|
278
|
-
</UFormField>
|
|
279
|
-
<UFormField
|
|
280
|
-
:label="protocol === 'oidc' ? 'Issuer URL' : 'IdP entity ID'"
|
|
281
|
-
:ui="formFieldUi"
|
|
282
|
-
>
|
|
283
|
-
<UInput
|
|
284
|
-
v-model="form.issuer"
|
|
285
|
-
required
|
|
286
|
-
:placeholder="
|
|
287
|
-
protocol === 'oidc' ? 'https://acme.okta.com' : 'http://www.okta.com/exk…'
|
|
288
|
-
"
|
|
289
|
-
:ui="fieldUi"
|
|
290
|
-
/>
|
|
291
|
-
</UFormField>
|
|
292
|
-
<template v-if="protocol === 'oidc'">
|
|
293
|
-
<UFormField
|
|
294
|
-
label="Client ID"
|
|
295
|
-
:ui="formFieldUi"
|
|
296
|
-
>
|
|
297
|
-
<UInput
|
|
298
|
-
v-model="form.clientId"
|
|
299
|
-
required
|
|
300
|
-
:ui="fieldUi"
|
|
301
|
-
/>
|
|
302
|
-
</UFormField>
|
|
303
|
-
<UFormField
|
|
304
|
-
label="Client secret"
|
|
305
|
-
:ui="formFieldUi"
|
|
306
|
-
>
|
|
307
|
-
<UInput
|
|
308
|
-
v-model="form.clientSecret"
|
|
309
|
-
required
|
|
310
|
-
type="password"
|
|
311
|
-
autocomplete="off"
|
|
312
|
-
:ui="fieldUi"
|
|
313
|
-
/>
|
|
314
|
-
</UFormField>
|
|
315
|
-
<p class="text-[13px] text-muted">
|
|
316
|
-
Redirect URI for your identity provider:
|
|
317
|
-
<code class="break-all text-highlighted">
|
|
318
|
-
{{ origin }}{{ authBase }}/sso/callback/{{
|
|
319
|
-
slugify(form.providerId || `${organization.slug}-oidc`)
|
|
320
|
-
}}
|
|
321
|
-
</code>
|
|
322
|
-
</p>
|
|
323
|
-
</template>
|
|
324
|
-
<template v-else>
|
|
325
|
-
<UFormField
|
|
326
|
-
label="Single sign-on URL"
|
|
327
|
-
:ui="formFieldUi"
|
|
328
|
-
>
|
|
329
|
-
<UInput
|
|
330
|
-
v-model="form.entryPoint"
|
|
331
|
-
required
|
|
332
|
-
type="url"
|
|
333
|
-
:ui="fieldUi"
|
|
334
|
-
/>
|
|
335
|
-
</UFormField>
|
|
336
|
-
<UFormField
|
|
337
|
-
label="IdP metadata XML"
|
|
338
|
-
description="Or paste the signing certificate below."
|
|
339
|
-
:ui="formFieldUi"
|
|
340
|
-
>
|
|
341
|
-
<UTextarea
|
|
342
|
-
v-model="form.metadata"
|
|
343
|
-
:rows="3"
|
|
344
|
-
class="w-full"
|
|
345
|
-
/>
|
|
346
|
-
</UFormField>
|
|
347
|
-
<UFormField
|
|
348
|
-
label="Signing certificate (PEM)"
|
|
349
|
-
:ui="formFieldUi"
|
|
350
|
-
>
|
|
351
|
-
<UTextarea
|
|
352
|
-
v-model="form.cert"
|
|
353
|
-
:rows="3"
|
|
354
|
-
class="w-full"
|
|
355
|
-
/>
|
|
356
|
-
</UFormField>
|
|
357
|
-
<p class="text-[13px] text-muted">
|
|
358
|
-
ACS URL for your identity provider:
|
|
359
|
-
<code class="break-all text-highlighted">
|
|
360
|
-
{{ origin }}{{ authBase }}/sso/saml2/sp/acs/{{
|
|
361
|
-
slugify(form.providerId || `${organization.slug}-saml`)
|
|
362
|
-
}}
|
|
363
|
-
</code>
|
|
364
|
-
</p>
|
|
365
|
-
</template>
|
|
366
|
-
<div class="flex flex-wrap gap-2">
|
|
367
|
-
<UButton
|
|
368
|
-
type="submit"
|
|
369
|
-
label="Add identity provider"
|
|
370
|
-
:loading="isBusy"
|
|
371
|
-
class="h-10 rounded-md px-4 text-sm font-medium"
|
|
372
|
-
/>
|
|
373
|
-
<UButton
|
|
374
|
-
type="button"
|
|
375
|
-
color="neutral"
|
|
376
|
-
variant="outline"
|
|
377
|
-
label="Cancel"
|
|
378
|
-
:disabled="isBusy"
|
|
379
|
-
class="h-10 rounded-md px-4 text-sm font-normal"
|
|
380
|
-
@click="adding = false"
|
|
381
|
-
/>
|
|
382
|
-
</div>
|
|
383
|
-
</form>
|
|
384
|
-
<UButton
|
|
385
|
-
v-else
|
|
386
|
-
type="button"
|
|
387
|
-
color="neutral"
|
|
388
|
-
variant="outline"
|
|
389
|
-
icon="i-lucide-plus"
|
|
390
|
-
label="Add identity provider"
|
|
391
|
-
class="h-10 w-fit rounded-md px-4 text-sm font-normal"
|
|
392
|
-
@click="adding = true"
|
|
393
|
-
/>
|
|
394
|
-
</template>
|
|
395
|
-
</SettingsSection>
|
|
396
|
-
</template>
|