@nuxtagentic/identity 0.0.0-stage → 0.1.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.
- package/LICENSE.md +9 -0
- package/README.md +52 -2
- package/module.ts +61 -0
- package/package.json +47 -3
- package/runtime/components/BrandMark.vue +8 -0
- package/runtime/components/CloudMascot.vue +34 -0
- package/runtime/components/CloudconfigLogo.vue +34 -0
- package/runtime/components/GithubAuthPanel.vue +158 -0
- package/runtime/components/ProfileSettings.vue +130 -0
- package/runtime/components/SettingsSection.vue +17 -0
- package/runtime/components/SocialAuthButton.vue +48 -0
- package/runtime/components/UserAvatar.vue +37 -0
- package/runtime/components/WorkspaceSwitcher.vue +118 -0
- package/runtime/components/settings/ImageField.vue +106 -0
- package/runtime/components/settings/OrganizationLogoEditor.vue +149 -0
- package/runtime/components/settings/Provisioning.vue +170 -0
- package/runtime/components/settings/Security.vue +211 -0
- package/runtime/components/settings/SettingsOrganizations.vue +305 -0
- package/runtime/components/settings/SingleSignOn.vue +396 -0
- package/runtime/components/settings/TwoFactor.vue +256 -0
- package/runtime/composables/useCurrentUser.ts +69 -0
- package/runtime/composables/useIdentityImageUpload.ts +31 -0
- package/runtime/composables/useOrganizationAccess.ts +46 -0
- package/runtime/composables/useProfileNamePreview.ts +4 -0
- package/runtime/composables/useWorkspaceScope.ts +12 -0
- package/runtime/composables/useWorkspaces.ts +108 -0
- package/runtime/layouts/default.vue +20 -0
- package/runtime/middleware/auth.ts +22 -0
- package/runtime/pages/organizations.vue +3 -0
- package/runtime/pages/sign-in.vue +7 -0
- package/runtime/pages/sign-up.vue +18 -0
- package/runtime/pages/two-factor.vue +148 -0
- package/runtime/settings-ui.ts +22 -0
- package/runtime/shared/agent.ts +1 -0
- package/runtime/utils/format.ts +18 -0
- package/runtime/workspace.ts +8 -0
- package/schema.ts +29 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch } from 'vue'
|
|
3
|
+
import { useWorkspaces } from '../composables/useWorkspaces'
|
|
4
|
+
|
|
5
|
+
import type { Organization } from '@cloudconfig/core/client'
|
|
6
|
+
|
|
7
|
+
const { collapsed = false } = defineProps<{ collapsed?: boolean }>()
|
|
8
|
+
|
|
9
|
+
const isOpen = ref(false)
|
|
10
|
+
const { activeOrganization, menu, switching, error, refresh } = await useWorkspaces(() => {
|
|
11
|
+
isOpen.value = false
|
|
12
|
+
})
|
|
13
|
+
|
|
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
|
+
watch(isOpen, (value) => {
|
|
23
|
+
if (value) {
|
|
24
|
+
void refresh()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
watch(
|
|
28
|
+
() => collapsed,
|
|
29
|
+
() => {
|
|
30
|
+
isOpen.value = false
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<span class="inline-block w-fit max-w-full min-w-0 self-center align-middle leading-none">
|
|
37
|
+
<UDropdownMenu
|
|
38
|
+
v-model:open="isOpen"
|
|
39
|
+
:items="menu"
|
|
40
|
+
:modal="false"
|
|
41
|
+
:content="{ align: 'start', sideOffset: 4 }"
|
|
42
|
+
:ui="{
|
|
43
|
+
content:
|
|
44
|
+
'z-30 box-border w-58 min-w-0 rounded-[10px] border border-default p-1.5 ring-0 origin-[24px_-16px] opacity-100 translate-y-0 scale-100 [clip-path:inset(0_0_0_0_round_10px)] transition-[clip-path,translate,scale,opacity] [transition-duration:240ms,240ms,240ms,160ms] ease-out starting:opacity-0 starting:-translate-y-1.5 starting:scale-[0.97] starting:[clip-path:inset(0_0_100%_0_round_10px)] data-[state=isOpen]:animate-none data-[state=closed]:animate-[fade-out_160ms_var(--ease-out)] data-[state=closed]:-translate-y-1.5 data-[state=closed]:scale-[0.97] data-[state=closed]:[clip-path:inset(0_0_100%_0_round_10px)] motion-reduce:animate-none motion-reduce:transition-none',
|
|
45
|
+
viewport: 'divide-y-0 scroll-py-0',
|
|
46
|
+
group: 'p-0',
|
|
47
|
+
label: 'gap-0 px-2.5 pt-1.5 pb-1 text-xs leading-[normal] font-normal text-muted',
|
|
48
|
+
item: 'h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-0 text-sm font-normal text-highlighted before:inset-0 before:rounded-md data-highlighted:before:bg-accented data-[state=isOpen]:before:bg-accented',
|
|
49
|
+
itemLeadingIcon: 'size-4 text-muted group-data-highlighted:text-muted',
|
|
50
|
+
itemTrailingIcon: 'size-4 text-highlighted',
|
|
51
|
+
separator: 'mx-1 my-1.5 h-px bg-(--ui-border)',
|
|
52
|
+
}"
|
|
53
|
+
>
|
|
54
|
+
<UButton
|
|
55
|
+
color="neutral"
|
|
56
|
+
variant="ghost"
|
|
57
|
+
size="md"
|
|
58
|
+
:square="collapsed"
|
|
59
|
+
:trailing-icon="collapsed ? undefined : 'i-lucide-chevron-down'"
|
|
60
|
+
:label="collapsed ? undefined : (activeOrganization?.name ?? 'Cloudconfig')"
|
|
61
|
+
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
|
+
:ui="{
|
|
63
|
+
label: 'min-w-0 truncate',
|
|
64
|
+
trailingIcon: 'size-4 text-muted',
|
|
65
|
+
}"
|
|
66
|
+
:aria-busy="switching"
|
|
67
|
+
aria-label="Open organization menu"
|
|
68
|
+
>
|
|
69
|
+
<template #leading>
|
|
70
|
+
<CloudconfigLogo
|
|
71
|
+
v-if="shouldShowDefaultLogo(activeOrganization)"
|
|
72
|
+
class="size-6"
|
|
73
|
+
/>
|
|
74
|
+
<UAvatar
|
|
75
|
+
v-else
|
|
76
|
+
:src="activeOrganization?.logo ?? undefined"
|
|
77
|
+
:alt="activeOrganization?.name ?? 'Cloudconfig'"
|
|
78
|
+
:text="(activeOrganization?.name ?? 'Cloudconfig').charAt(0).toUpperCase()"
|
|
79
|
+
size="xs"
|
|
80
|
+
:ui="{
|
|
81
|
+
root: 'rounded-md bg-(--gray-500)',
|
|
82
|
+
image: 'object-contain',
|
|
83
|
+
fallback: 'text-xs font-semibold',
|
|
84
|
+
}"
|
|
85
|
+
/>
|
|
86
|
+
</template>
|
|
87
|
+
</UButton>
|
|
88
|
+
|
|
89
|
+
<template #workspace-leading="{ item }">
|
|
90
|
+
<CloudconfigLogo
|
|
91
|
+
v-if="shouldShowDefaultLogo(item.workspace ?? null)"
|
|
92
|
+
class="size-5"
|
|
93
|
+
/>
|
|
94
|
+
<UAvatar
|
|
95
|
+
v-else
|
|
96
|
+
:src="item.workspace?.logo ?? undefined"
|
|
97
|
+
:alt="item.label"
|
|
98
|
+
:text="item.label?.charAt(0).toUpperCase()"
|
|
99
|
+
:ui="{
|
|
100
|
+
root: 'size-5 rounded-[5px] bg-(--gray-500)',
|
|
101
|
+
image: 'object-contain',
|
|
102
|
+
fallback: 'text-[11px] font-semibold',
|
|
103
|
+
}"
|
|
104
|
+
/>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<template #content-bottom>
|
|
108
|
+
<p
|
|
109
|
+
v-if="error"
|
|
110
|
+
role="alert"
|
|
111
|
+
class="px-2.5 py-1.5 text-xs leading-normal text-error"
|
|
112
|
+
>
|
|
113
|
+
Could not load organizations. Reopen the menu to try again.
|
|
114
|
+
</p>
|
|
115
|
+
</template>
|
|
116
|
+
</UDropdownMenu>
|
|
117
|
+
</span>
|
|
118
|
+
</template>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useTemplateRef, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { useObjectUrl } from '@vueuse/core'
|
|
5
|
+
|
|
6
|
+
const file = defineModel<File | null>('file', { required: true })
|
|
7
|
+
const image = defineModel<string | null>('image', { required: true })
|
|
8
|
+
const {
|
|
9
|
+
name,
|
|
10
|
+
identity,
|
|
11
|
+
disabled,
|
|
12
|
+
kind = 'avatar',
|
|
13
|
+
} = defineProps<{
|
|
14
|
+
name: string
|
|
15
|
+
identity?: string | number
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
kind?: 'avatar' | 'logo'
|
|
18
|
+
}>()
|
|
19
|
+
const input = useTemplateRef<HTMLInputElement>('input')
|
|
20
|
+
const preview = useObjectUrl(file)
|
|
21
|
+
const error = ref('')
|
|
22
|
+
|
|
23
|
+
function select(event: Event) {
|
|
24
|
+
const target = event.target as HTMLInputElement
|
|
25
|
+
const selected = target.files?.[0]
|
|
26
|
+
target.value = ''
|
|
27
|
+
if (!selected) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
error.value = ''
|
|
31
|
+
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(selected.type)) {
|
|
32
|
+
error.value = 'Choose a JPG, PNG, WebP, or GIF image.'
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (selected.size > 2 * 1024 * 1024) {
|
|
36
|
+
error.value = 'Choose an image smaller than 2 MB.'
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
file.value = selected
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function remove() {
|
|
43
|
+
file.value = null
|
|
44
|
+
image.value = null
|
|
45
|
+
error.value = ''
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="space-y-2">
|
|
51
|
+
<div class="flex items-center gap-4">
|
|
52
|
+
<UserAvatar
|
|
53
|
+
v-if="kind === 'avatar'"
|
|
54
|
+
:src="preview || image"
|
|
55
|
+
:name="name"
|
|
56
|
+
:identity="identity"
|
|
57
|
+
size="xl"
|
|
58
|
+
/>
|
|
59
|
+
<UAvatar
|
|
60
|
+
v-else
|
|
61
|
+
:src="preview || image || undefined"
|
|
62
|
+
:alt="name || 'Organization logo'"
|
|
63
|
+
:text="name.charAt(0).toUpperCase()"
|
|
64
|
+
icon="i-lucide-building-2"
|
|
65
|
+
size="xl"
|
|
66
|
+
:ui="{ root: 'rounded-lg bg-(--gray-500)', image: 'object-contain' }"
|
|
67
|
+
/>
|
|
68
|
+
<div class="flex min-w-0 flex-col gap-1.5">
|
|
69
|
+
<div class="flex items-center gap-2">
|
|
70
|
+
<UButton
|
|
71
|
+
type="button"
|
|
72
|
+
variant="outline"
|
|
73
|
+
:label="image || file ? `Change ${kind}` : `Upload ${kind}`"
|
|
74
|
+
:disabled="disabled"
|
|
75
|
+
@click="input?.click()"
|
|
76
|
+
/>
|
|
77
|
+
<UButton
|
|
78
|
+
v-if="image || file"
|
|
79
|
+
type="button"
|
|
80
|
+
variant="ghost"
|
|
81
|
+
label="Remove"
|
|
82
|
+
:disabled="disabled"
|
|
83
|
+
@click="remove"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
<p class="text-xs text-muted">JPG, PNG, WebP, or GIF. Up to 2 MB.</p>
|
|
87
|
+
</div>
|
|
88
|
+
<input
|
|
89
|
+
ref="input"
|
|
90
|
+
type="file"
|
|
91
|
+
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
92
|
+
:aria-label="`Choose ${kind === 'logo' ? 'an organization logo' : 'an avatar image'}`"
|
|
93
|
+
class="hidden"
|
|
94
|
+
:disabled="disabled"
|
|
95
|
+
@change="select"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<p
|
|
99
|
+
v-if="error"
|
|
100
|
+
role="alert"
|
|
101
|
+
class="text-[13px] text-error"
|
|
102
|
+
>
|
|
103
|
+
{{ error }}
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAsyncData, useCloudConfigOrganizations } from '#imports'
|
|
3
|
+
import { computed, ref, shallowRef } from 'vue'
|
|
4
|
+
import type { Organization } from '@cloudconfig/core/client'
|
|
5
|
+
import { useCurrentUser } from '../../composables/useCurrentUser'
|
|
6
|
+
import { useIdentityImageUpload } from '../../composables/useIdentityImageUpload'
|
|
7
|
+
import { formFieldUi } from '../../settings-ui'
|
|
8
|
+
|
|
9
|
+
const { organization } = defineProps<{ organization: Organization }>()
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
saved: [organization: Organization]
|
|
12
|
+
cancel: []
|
|
13
|
+
'update:saving': [isSaving: boolean]
|
|
14
|
+
}>()
|
|
15
|
+
const organizations = useCloudConfigOrganizations()
|
|
16
|
+
const user = useCurrentUser()
|
|
17
|
+
const imageUpload = useIdentityImageUpload()
|
|
18
|
+
const logo = ref(organization.logo)
|
|
19
|
+
const logoFile = shallowRef<File | null>(null)
|
|
20
|
+
const isSaving = ref(false)
|
|
21
|
+
const saveError = ref('')
|
|
22
|
+
const {
|
|
23
|
+
data: details,
|
|
24
|
+
pending,
|
|
25
|
+
error: loadError,
|
|
26
|
+
refresh,
|
|
27
|
+
} = useAsyncData(
|
|
28
|
+
() => `organization-logo-${organization.id}`,
|
|
29
|
+
() => organizations.getFull(organization.id),
|
|
30
|
+
)
|
|
31
|
+
const canEditLogo = computed(() => {
|
|
32
|
+
const membership = details.value?.members.find(
|
|
33
|
+
(member) => String(member.userId) === String(user.value?.id),
|
|
34
|
+
)
|
|
35
|
+
return (
|
|
36
|
+
membership?.role.split(',').some((role) => ['owner', 'admin'].includes(role.trim())) ?? false
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
const hasChanges = computed(() => !!logoFile.value || logo.value !== organization.logo)
|
|
40
|
+
|
|
41
|
+
async function saveLogo() {
|
|
42
|
+
if (
|
|
43
|
+
isSaving.value ||
|
|
44
|
+
pending.value ||
|
|
45
|
+
loadError.value ||
|
|
46
|
+
!canEditLogo.value ||
|
|
47
|
+
!hasChanges.value
|
|
48
|
+
) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isSaving.value = true
|
|
53
|
+
emit('update:saving', true)
|
|
54
|
+
saveError.value = ''
|
|
55
|
+
try {
|
|
56
|
+
const image = logoFile.value ? await imageUpload.upload(logoFile.value) : logo.value
|
|
57
|
+
const updatedOrganization = await organizations.update(organization.id, { logo: image })
|
|
58
|
+
imageUpload.clear()
|
|
59
|
+
emit('saved', updatedOrganization)
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
saveError.value =
|
|
62
|
+
cause instanceof Error ? cause.message : 'Could not save the organization logo.'
|
|
63
|
+
} finally {
|
|
64
|
+
isSaving.value = false
|
|
65
|
+
emit('update:saving', false)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<form
|
|
72
|
+
class="grid gap-4 rounded-lg border border-default p-4"
|
|
73
|
+
:aria-busy="pending || isSaving"
|
|
74
|
+
@submit.prevent="saveLogo"
|
|
75
|
+
@keydown.esc.stop.prevent="!isSaving && emit('cancel')"
|
|
76
|
+
>
|
|
77
|
+
<p class="text-sm text-highlighted">{{ organization.name }} logo</p>
|
|
78
|
+
<p
|
|
79
|
+
v-if="pending"
|
|
80
|
+
role="status"
|
|
81
|
+
class="text-[13px] text-muted"
|
|
82
|
+
>
|
|
83
|
+
Loading organization permissions…
|
|
84
|
+
</p>
|
|
85
|
+
<div
|
|
86
|
+
v-else-if="loadError"
|
|
87
|
+
class="space-y-2"
|
|
88
|
+
>
|
|
89
|
+
<p
|
|
90
|
+
role="alert"
|
|
91
|
+
class="text-[13px] text-error"
|
|
92
|
+
>
|
|
93
|
+
{{ loadError.message || 'Could not load organization permissions.' }}
|
|
94
|
+
</p>
|
|
95
|
+
<UButton
|
|
96
|
+
type="button"
|
|
97
|
+
label="Try again"
|
|
98
|
+
color="neutral"
|
|
99
|
+
variant="outline"
|
|
100
|
+
@click="refresh()"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<UFormField
|
|
104
|
+
v-else-if="canEditLogo"
|
|
105
|
+
label="Logo"
|
|
106
|
+
:ui="formFieldUi"
|
|
107
|
+
>
|
|
108
|
+
<SettingsImageField
|
|
109
|
+
v-model:file="logoFile"
|
|
110
|
+
v-model:image="logo"
|
|
111
|
+
kind="logo"
|
|
112
|
+
:name="organization.name"
|
|
113
|
+
:disabled="isSaving"
|
|
114
|
+
/>
|
|
115
|
+
</UFormField>
|
|
116
|
+
<p
|
|
117
|
+
v-else
|
|
118
|
+
class="text-[13px] text-muted"
|
|
119
|
+
>
|
|
120
|
+
Only organization owners and admins can change the logo.
|
|
121
|
+
</p>
|
|
122
|
+
<p
|
|
123
|
+
v-if="saveError"
|
|
124
|
+
role="alert"
|
|
125
|
+
class="text-[13px] text-error"
|
|
126
|
+
>
|
|
127
|
+
{{ saveError }}
|
|
128
|
+
</p>
|
|
129
|
+
<div class="flex flex-wrap gap-2">
|
|
130
|
+
<UButton
|
|
131
|
+
v-if="canEditLogo"
|
|
132
|
+
type="submit"
|
|
133
|
+
label="Save logo"
|
|
134
|
+
:loading="isSaving"
|
|
135
|
+
:disabled="pending || !hasChanges"
|
|
136
|
+
class="h-10 rounded-md px-4 text-sm font-medium"
|
|
137
|
+
/>
|
|
138
|
+
<UButton
|
|
139
|
+
type="button"
|
|
140
|
+
color="neutral"
|
|
141
|
+
variant="outline"
|
|
142
|
+
label="Cancel"
|
|
143
|
+
:disabled="isSaving"
|
|
144
|
+
class="h-10 rounded-md px-4 text-sm font-normal"
|
|
145
|
+
@click="emit('cancel')"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
</form>
|
|
149
|
+
</template>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue'
|
|
3
|
+
import type { ScimToken } from '@cloudconfig/core/client'
|
|
4
|
+
import { useOrganizationAdmin, useSsoClient } from '../../composables/useOrganizationAccess'
|
|
5
|
+
import { fieldUi } from '../../settings-ui'
|
|
6
|
+
|
|
7
|
+
const sso = useSsoClient()
|
|
8
|
+
const { organization, isAdmin } = useOrganizationAdmin()
|
|
9
|
+
const tokens = ref<ScimToken[]>([])
|
|
10
|
+
const baseUrl = ref('')
|
|
11
|
+
const created = ref('')
|
|
12
|
+
const available = ref(true)
|
|
13
|
+
const isBusy = ref(false)
|
|
14
|
+
const error = ref('')
|
|
15
|
+
|
|
16
|
+
async function load() {
|
|
17
|
+
if (!isAdmin.value) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const result = await sso.scim.list()
|
|
22
|
+
tokens.value = result.tokens
|
|
23
|
+
baseUrl.value = result.baseUrl
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
const status = (cause as { status?: number }).status
|
|
26
|
+
if (status === 404) {
|
|
27
|
+
available.value = false
|
|
28
|
+
} else {
|
|
29
|
+
error.value = cause instanceof Error ? cause.message : 'Could not load provisioning.'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
onMounted(load)
|
|
34
|
+
watch(isAdmin, load)
|
|
35
|
+
|
|
36
|
+
async function run(work: () => Promise<unknown>) {
|
|
37
|
+
if (isBusy.value) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
isBusy.value = true
|
|
41
|
+
error.value = ''
|
|
42
|
+
try {
|
|
43
|
+
await work()
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
error.value = cause instanceof Error ? cause.message : 'Something went wrong.'
|
|
46
|
+
} finally {
|
|
47
|
+
isBusy.value = false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const create = () =>
|
|
52
|
+
run(async () => {
|
|
53
|
+
const secret = await sso.scim.create('Directory')
|
|
54
|
+
created.value = secret.token
|
|
55
|
+
baseUrl.value = secret.baseUrl
|
|
56
|
+
await load()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const revoke = (token: ScimToken) =>
|
|
60
|
+
run(async () => {
|
|
61
|
+
await sso.scim.revoke(token.uid)
|
|
62
|
+
await load()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function date(value: number | null) {
|
|
66
|
+
return value ? new Date(value).toLocaleDateString() : 'never'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function copy(value: string) {
|
|
70
|
+
error.value = ''
|
|
71
|
+
try {
|
|
72
|
+
await navigator.clipboard.writeText(value)
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
error.value = cause instanceof Error ? cause.message : 'Could not copy to the clipboard.'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<SettingsSection
|
|
81
|
+
v-if="available && organization && isAdmin"
|
|
82
|
+
title="Directory provisioning"
|
|
83
|
+
description="Okta, Entra ID or Google Workspace add and remove members over SCIM. Removing someone there ends their access here."
|
|
84
|
+
>
|
|
85
|
+
<p
|
|
86
|
+
v-if="error"
|
|
87
|
+
role="alert"
|
|
88
|
+
class="text-[13px] text-error"
|
|
89
|
+
>
|
|
90
|
+
{{ error }}
|
|
91
|
+
</p>
|
|
92
|
+
<div class="grid gap-2">
|
|
93
|
+
<span class="text-[13px] text-muted">SCIM base URL</span>
|
|
94
|
+
<div class="flex gap-2">
|
|
95
|
+
<UInput
|
|
96
|
+
:model-value="baseUrl"
|
|
97
|
+
readonly
|
|
98
|
+
aria-label="SCIM base URL"
|
|
99
|
+
:ui="fieldUi"
|
|
100
|
+
class="flex-1"
|
|
101
|
+
/>
|
|
102
|
+
<UButton
|
|
103
|
+
label="Copy"
|
|
104
|
+
color="neutral"
|
|
105
|
+
variant="outline"
|
|
106
|
+
class="h-10 rounded-md px-3 text-sm font-normal"
|
|
107
|
+
@click="copy(baseUrl)"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div
|
|
112
|
+
v-if="created"
|
|
113
|
+
class="grid gap-2 rounded-md border border-default p-3"
|
|
114
|
+
>
|
|
115
|
+
<p class="text-[13px] text-muted">
|
|
116
|
+
Paste this token into your identity provider now. It is shown once.
|
|
117
|
+
</p>
|
|
118
|
+
<div class="flex gap-2">
|
|
119
|
+
<UInput
|
|
120
|
+
:model-value="created"
|
|
121
|
+
readonly
|
|
122
|
+
aria-label="New SCIM token"
|
|
123
|
+
:ui="fieldUi"
|
|
124
|
+
class="flex-1"
|
|
125
|
+
/>
|
|
126
|
+
<UButton
|
|
127
|
+
label="Copy"
|
|
128
|
+
color="neutral"
|
|
129
|
+
class="h-10 rounded-md px-3 text-sm font-normal"
|
|
130
|
+
@click="copy(created)"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
<div
|
|
135
|
+
v-for="token in tokens"
|
|
136
|
+
:key="token.uid"
|
|
137
|
+
class="flex flex-wrap items-center justify-between gap-3"
|
|
138
|
+
>
|
|
139
|
+
<div class="min-w-0">
|
|
140
|
+
<p class="truncate text-sm text-highlighted">{{ token.label || 'Token' }}</p>
|
|
141
|
+
<p class="mt-0.5 truncate text-[13px] text-muted">
|
|
142
|
+
{{
|
|
143
|
+
token.revokedAt
|
|
144
|
+
? `Revoked ${date(token.revokedAt)}`
|
|
145
|
+
: `Last used ${date(token.lastUsedAt)} · expires ${date(token.expiresAt)}`
|
|
146
|
+
}}
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
<UButton
|
|
150
|
+
v-if="!token.revokedAt"
|
|
151
|
+
label="Revoke"
|
|
152
|
+
color="neutral"
|
|
153
|
+
variant="outline"
|
|
154
|
+
:disabled="isBusy"
|
|
155
|
+
class="h-9 rounded-md px-3 text-sm font-normal"
|
|
156
|
+
@click="revoke(token)"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<UButton
|
|
160
|
+
type="button"
|
|
161
|
+
color="neutral"
|
|
162
|
+
variant="outline"
|
|
163
|
+
icon="i-lucide-key-round"
|
|
164
|
+
label="Create SCIM token"
|
|
165
|
+
:loading="isBusy"
|
|
166
|
+
class="h-10 w-fit rounded-md px-4 text-sm font-normal"
|
|
167
|
+
@click="create"
|
|
168
|
+
/>
|
|
169
|
+
</SettingsSection>
|
|
170
|
+
</template>
|