Skip to content

Commit 233b930

Browse files
committed
feat: implement external identity management with connected accounts feature
1 parent 0d5b507 commit 233b930

4 files changed

Lines changed: 602 additions & 79 deletions

File tree

custom/OAuthCallback.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<script setup>
2424
import { onMounted, ref } from 'vue';
2525
import { useUserStore } from '@/stores/user';
26+
import { useCoreStore } from '@/stores/core';
2627
import { useRouter, useRoute } from 'vue-router';
2728
import { callAdminForthApi } from '@/utils';
2829
import { Spinner, LinkButton } from '@/afcl';
@@ -32,6 +33,7 @@ const { t } = useI18n();
3233
3334
const router = useRouter();
3435
const userStore = useUserStore();
36+
const coreStore = useCoreStore();
3537
const route = useRoute();
3638
const error = ref(null);
3739
@@ -47,14 +49,16 @@ onMounted(async () => {
4749
if (code && state && redirectUri) {
4850
const encodedCode = encodeURIComponent(code);
4951
const encodedState = encodeURIComponent(state);
52+
const encodedRedirectUri = encodeURIComponent(redirectUri);
5053
const response = await callAdminForthApi({
51-
path: `/oauth/callback?code=${encodedCode}&state=${encodedState}&redirect_uri=${redirectUri}`,
54+
path: `/oauth/callback?code=${encodedCode}&state=${encodedState}&redirect_uri=${encodedRedirectUri}`,
5255
method: 'POST',
5356
});
5457
5558
if (response.allowedLogin) {
5659
await userStore.finishLogin();
5760
} else if (response.redirectTo) {
61+
await coreStore.fetchMenuAndResource();
5862
router.push(response.redirectTo);
5963
} else if (response.error) {
6064
error.value = response.error;

custom/OAuthConnectedAccounts.vue

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<template>
2+
<div class="flex flex-col justify-center mr-6 md:mr-12">
3+
<h2 class="flex items-start justify-start leading-none text-gray-800 dark:text-gray-50 text-3xl font-semibold">
4+
{{ $t('Connected Accounts') }}
5+
</h2>
6+
<p class="text-sm mt-3">
7+
{{ $t('Connect external accounts to your AdminForth user') }}
8+
</p>
9+
10+
<div class="mt-6 flex flex-wrap gap-4">
11+
<div
12+
v-for="provider in providers"
13+
:key="provider.provider"
14+
class="flex flex-col w-full lg:w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm"
15+
>
16+
<div class="flex items-center justify-between gap-3 mb-4">
17+
<div class="min-w-0 flex items-center gap-3">
18+
<div v-html="provider.icon" class="w-6 h-6 shrink-0 dark:text-white" />
19+
<div class="min-w-0">
20+
<p class="font-semibold text-gray-900 dark:text-white truncate">
21+
{{ provider.name }}
22+
</p>
23+
<p class="text-xs text-gray-500 dark:text-gray-400">
24+
{{ provider.connected ? $t('Connected') : $t('Not connected') }}
25+
</p>
26+
</div>
27+
</div>
28+
<span
29+
class="shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
30+
:class="provider.connected
31+
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
32+
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
33+
>
34+
{{ provider.connected ? $t('Active') : $t('Inactive') }}
35+
</span>
36+
</div>
37+
38+
<Button
39+
class="w-full mt-auto"
40+
:disabled="connectingProvider === provider.provider"
41+
:loader="connectingProvider === provider.provider"
42+
@click="connectProvider(provider.provider)"
43+
>
44+
{{ provider.connected ? $t('Connect another') : $t('Connect') }}
45+
</Button>
46+
</div>
47+
</div>
48+
49+
<div v-if="identities.length" class="mt-6 overflow-hidden border border-gray-200 dark:border-gray-700 rounded-lg">
50+
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
51+
<thead class="bg-gray-50 dark:bg-gray-800">
52+
<tr>
53+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
54+
{{ $t('Account') }}
55+
</th>
56+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
57+
{{ $t('Provider') }}
58+
</th>
59+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
60+
{{ $t('Identifier') }}
61+
</th>
62+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
63+
{{ $t('Actions') }}
64+
</th>
65+
</tr>
66+
</thead>
67+
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
68+
<tr v-for="identity in identities" :key="`${identity.provider}:${identity.subject}`">
69+
<td class="px-4 py-3">
70+
<div class="flex items-center gap-3">
71+
<img
72+
v-if="identity.avatarUrl"
73+
:src="identity.avatarUrl"
74+
class="h-9 w-9 rounded-full object-cover"
75+
alt=""
76+
/>
77+
<div v-else class="h-9 w-9 rounded-full bg-gray-200 dark:bg-gray-700" />
78+
<div class="min-w-0">
79+
<p class="truncate font-medium text-gray-900 dark:text-white">
80+
{{ identity.fullName || identity.email || identity.phone || identity.subject }}
81+
</p>
82+
<p v-if="identity.email || identity.phone" class="truncate text-xs text-gray-500 dark:text-gray-400">
83+
{{ identity.email || identity.phone }}
84+
</p>
85+
</div>
86+
</div>
87+
</td>
88+
<td class="px-4 py-3">
89+
<div class="flex items-center gap-2">
90+
<div v-if="identity.providerIcon" v-html="identity.providerIcon" class="h-5 w-5 shrink-0" />
91+
<span class="text-sm text-gray-700 dark:text-gray-200">{{ identity.providerName }}</span>
92+
</div>
93+
</td>
94+
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
95+
{{ identity.subject }}
96+
</td>
97+
<td class="px-4 py-3 text-right">
98+
<button
99+
type="button"
100+
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 disabled:pointer-events-none disabled:opacity-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950"
101+
:disabled="disconnectingIdentity === identity.id"
102+
@click="disconnectIdentity(identity.id)"
103+
>
104+
{{ disconnectingIdentity === identity.id ? $t('Disconnecting') : $t('Disconnect') }}
105+
</button>
106+
</td>
107+
</tr>
108+
</tbody>
109+
</table>
110+
</div>
111+
</div>
112+
</template>
113+
114+
<script setup lang="ts">
115+
import { onMounted, ref } from 'vue';
116+
import { Button } from '@/afcl';
117+
import { callAdminForthApi } from '@/utils';
118+
import { useCoreStore } from '@/stores/core';
119+
120+
type OAuthProvider = {
121+
provider: string;
122+
name: string;
123+
icon: string;
124+
connected: boolean;
125+
};
126+
127+
type OAuthIdentity = {
128+
id: string;
129+
provider: string;
130+
providerName: string;
131+
providerIcon?: string;
132+
subject: string;
133+
email?: string | null;
134+
phone?: string | null;
135+
fullName?: string | null;
136+
avatarUrl?: string | null;
137+
};
138+
139+
const providers = ref<OAuthProvider[]>([]);
140+
const identities = ref<OAuthIdentity[]>([]);
141+
const connectingProvider = ref<string | null>(null);
142+
const disconnectingIdentity = ref<string | null>(null);
143+
const coreStore = useCoreStore();
144+
145+
onMounted(async () => {
146+
await loadProviders();
147+
});
148+
149+
async function loadProviders() {
150+
const response = await callAdminForthApi({
151+
method: 'POST',
152+
path: '/oauth/external-identities',
153+
body: {},
154+
});
155+
156+
providers.value = response.providers;
157+
identities.value = response.identities || [];
158+
}
159+
160+
async function connectProvider(provider: string) {
161+
connectingProvider.value = provider;
162+
163+
try {
164+
const response = await callAdminForthApi({
165+
method: 'POST',
166+
path: '/oauth/external-identity/connect-action',
167+
body: {
168+
provider,
169+
redirectUri: getRedirectUri(),
170+
},
171+
});
172+
173+
if (response.action.type === 'url') {
174+
window.location.href = response.action.url;
175+
}
176+
} finally {
177+
connectingProvider.value = null;
178+
}
179+
}
180+
181+
async function disconnectIdentity(identityId: string) {
182+
disconnectingIdentity.value = identityId;
183+
184+
try {
185+
await callAdminForthApi({
186+
method: 'POST',
187+
path: '/oauth/external-identity/disconnect',
188+
body: { identityId },
189+
});
190+
await loadProviders();
191+
} finally {
192+
disconnectingIdentity.value = null;
193+
}
194+
}
195+
196+
function getRedirectUri() {
197+
const baseUrl = coreStore.config?.baseUrl || '';
198+
const baseUrlSlashed = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
199+
return `${window.location.origin}${baseUrlSlashed}oauth/callback`;
200+
}
201+
</script>

externalIdentityStore.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Filters } from "adminforth";
2+
import type { IAdminForth } from "adminforth";
3+
4+
export interface ExternalIdentityResourceOptions {
5+
resourceId: string;
6+
adminUserIdField?: string;
7+
providerField?: string;
8+
subjectField?: string;
9+
emailField?: string;
10+
phoneField?: string;
11+
fullNameField?: string;
12+
avatarUrlField?: string;
13+
externalUserIdField?: string;
14+
metaField?: string;
15+
}
16+
17+
export interface OAuthIdentity {
18+
provider: string;
19+
subject: string;
20+
email?: string;
21+
phone?: string;
22+
meta?: Record<string, any>;
23+
fullName?: string;
24+
profilePictureUrl?: string | null;
25+
externalUserId?: string | number | null;
26+
}
27+
28+
type OAuthAdapterDisplay = {
29+
constructor: { name: string };
30+
getName?: () => string;
31+
getIcon: () => string;
32+
};
33+
34+
export class ExternalIdentityStore {
35+
private primaryKeyField: string;
36+
private adminUserIdField: string;
37+
private providerField: string;
38+
private subjectField: string;
39+
private externalUserIdField: string;
40+
41+
constructor(
42+
private adminforth: IAdminForth,
43+
private config: ExternalIdentityResourceOptions,
44+
) {
45+
this.primaryKeyField = this.resolvePrimaryKey();
46+
this.adminUserIdField = config.adminUserIdField ?? 'adminUserId';
47+
this.providerField = config.providerField ?? 'provider';
48+
this.subjectField = config.subjectField ?? 'subject';
49+
this.externalUserIdField = config.externalUserIdField ?? 'externalUserId';
50+
}
51+
52+
private resource() {
53+
return this.adminforth.resource(this.config.resourceId);
54+
}
55+
56+
private resolvePrimaryKey() {
57+
const primaryKey = this.adminforth.config.resources
58+
.find(resource => resource.resourceId === this.config.resourceId)
59+
?.columns.find(column => column.primaryKey)?.name;
60+
61+
if (!primaryKey) {
62+
throw new Error(`External identity resource "${this.config.resourceId}" has no primary key`);
63+
}
64+
65+
return primaryKey;
66+
}
67+
68+
private identityRecord(adminUserPk: any, identity: OAuthIdentity) {
69+
const record: Record<string, any> = {
70+
[this.adminUserIdField]: adminUserPk,
71+
[this.providerField]: identity.provider,
72+
[this.subjectField]: identity.subject,
73+
};
74+
const fields = [
75+
[this.config.emailField, identity.email],
76+
[this.config.phoneField, identity.phone],
77+
[this.config.fullNameField, identity.fullName],
78+
[this.config.avatarUrlField, identity.profilePictureUrl],
79+
[this.externalUserIdField, identity.externalUserId],
80+
[this.config.metaField, identity.meta],
81+
] as const;
82+
83+
for (const [field, value] of fields) {
84+
if (field && value !== undefined) {
85+
record[field] = value;
86+
}
87+
}
88+
89+
return record;
90+
}
91+
92+
findByIdentity(identityPayload: OAuthIdentity) {
93+
return this.resource().get([
94+
Filters.EQ(this.providerField, identityPayload.provider),
95+
Filters.EQ(this.subjectField, identityPayload.subject),
96+
]);
97+
}
98+
99+
linkedAdminUserPk(identity: any) {
100+
return identity[this.adminUserIdField];
101+
}
102+
103+
async createOrUpdate(adminUserPk: any, identityPayload: OAuthIdentity) {
104+
const existingIdentity = await this.findByIdentity(identityPayload);
105+
const identityRecord = this.identityRecord(adminUserPk, identityPayload);
106+
107+
if (existingIdentity) {
108+
if (existingIdentity[this.adminUserIdField] !== adminUserPk) {
109+
throw new Error('This external account is already connected to another user');
110+
}
111+
await this.resource().update(
112+
existingIdentity[this.primaryKeyField],
113+
identityRecord,
114+
);
115+
return;
116+
}
117+
118+
await this.resource().create(identityRecord);
119+
}
120+
121+
async disconnect(identityId: string, adminUserPk: any) {
122+
const identity = await this.resource().get([
123+
Filters.EQ(this.primaryKeyField, identityId),
124+
Filters.EQ(this.adminUserIdField, adminUserPk),
125+
]);
126+
127+
if (!identity) {
128+
return { error: 'Connected account not found' };
129+
}
130+
131+
await this.resource().delete(identityId);
132+
return { ok: true };
133+
}
134+
135+
async connectedAccounts(adminUserPk: any, adapters: OAuthAdapterDisplay[]) {
136+
const identities = await this.resource().list(
137+
Filters.EQ(this.adminUserIdField, adminUserPk)
138+
);
139+
140+
return {
141+
providers: adapters.map((adapter) => ({
142+
provider: adapter.constructor.name,
143+
name: adapter.getName ? adapter.getName() : adapter.constructor.name,
144+
icon: adapter.getIcon(),
145+
connected: identities.some((identity) => identity[this.providerField] === adapter.constructor.name),
146+
})),
147+
identities: identities.map((identity) => {
148+
const adapter = adapters.find((adapter) => adapter.constructor.name === identity[this.providerField]);
149+
return {
150+
id: identity[this.primaryKeyField],
151+
provider: identity[this.providerField],
152+
providerName: adapter?.getName ? adapter.getName() : identity[this.providerField],
153+
providerIcon: adapter?.getIcon(),
154+
subject: identity[this.subjectField],
155+
email: this.config.emailField ? identity[this.config.emailField] : null,
156+
phone: this.config.phoneField ? identity[this.config.phoneField] : null,
157+
fullName: this.config.fullNameField ? identity[this.config.fullNameField] : null,
158+
avatarUrl: this.config.avatarUrlField ? identity[this.config.avatarUrlField] : null,
159+
};
160+
}),
161+
};
162+
}
163+
}

0 commit comments

Comments
 (0)