Skip to content

Commit efdf5c5

Browse files
authoredDec 26, 2023
feat(vue): add organization support (#604)
* feat(vue): add organization support * fix: fixup
1 parent 5f5c7d5 commit efdf5c5

17 files changed

+669
-402
lines changed
 

‎.changeset/chilly-donuts-shout.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@logto/client": patch
3+
---
4+
5+
Fix a potential build issue in Vue, which is caused by using private method in Client SDK.

‎.changeset/honest-experts-leave.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@logto/vue": minor
3+
"@logto/vue-sample": minor
4+
---
5+
6+
Support organizations in Vue SDK

‎.vscode/settings.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"typescript.tsdk": "node_modules/typescript/lib",
33
"[scss]": {
44
"editor.codeActionsOnSave": {
5-
"source.fixAll.stylelint": true
5+
"source.fixAll.stylelint": "explicit"
66
}
77
},
88
"cssVariables.lookupFiles": [
@@ -20,7 +20,7 @@
2020
"typescriptreact",
2121
],
2222
"editor.codeActionsOnSave": {
23-
"source.fixAll.eslint": true,
23+
"source.fixAll.eslint": "explicit"
2424
},
2525
"cSpell.words": [
2626
"logto",

‎packages/client/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ export default class LogtoClient {
473473
throw new LogtoClientError('missing_scope_organizations');
474474
}
475475

476-
return this.#getAccessToken(undefined, organizationId);
476+
return this.getAccessToken(undefined, organizationId);
477477
}
478478

479479
async #handleSignInCallback(callbackUri: string) {

‎packages/react-sample/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
},
1414
"dependencies": {
1515
"@logto/react": "workspace:^2.2.0",
16+
"@tanstack/react-query": "^5.0.0",
1617
"react": "^18.2.0",
1718
"react-dom": "^18.2.0",
18-
"@tanstack/react-query": "^5.0.0",
1919
"react-router-dom": "^6.2.2"
2020
},
2121
"devDependencies": {

‎packages/vue-sample/package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@logto/vue": "workspace:^2.0.0",
15-
"vue": "^3.2.35",
15+
"vue": "^3.3.13",
1616
"vue-router": "^4.0.14"
1717
},
1818
"devDependencies": {
@@ -28,7 +28,7 @@
2828
"lint-staged": "^15.0.0",
2929
"prettier": "^3.0.0",
3030
"typescript": "^5.0.0",
31-
"vite": "^4.2.3",
32-
"vue-tsc": "^1.2.0"
31+
"vite": "^5.0.10",
32+
"vue-tsc": "^1.8.26"
3333
}
3434
}
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const baseUrl = window.location.origin;
22
export const redirectUrl = `${baseUrl}/callback`;
33

4-
export const appId = "sampleId"; // Register the sample app in Logto dashboard
4+
export const appId = "sampleId"; // Register the sample app in Logto admin console
55
export const endpoint = "http://localhost:3001"; // Replace with your own Logto endpoint
6+
export const resource = "your-resource-identifier"; // Replace with your own API resource identifier

‎packages/vue-sample/src/main.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@ import { createApp } from "vue";
22
import App from "./App.vue";
33
import router from "./router";
44
import { createLogto, UserScope } from "@logto/vue";
5-
import { appId, endpoint } from "./consts";
5+
import { appId, endpoint, resource } from "./consts";
66

77
const app = createApp(App);
88

99
app.use(createLogto, {
1010
appId,
1111
endpoint,
12+
resources: [resource],
1213
scopes: [
1314
UserScope.Email,
1415
UserScope.Phone,
1516
UserScope.CustomData,
1617
UserScope.Identities,
18+
UserScope.Organizations,
1719
],
1820
});
1921
app.use(router);

‎packages/vue-sample/src/router/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const router = createRouter({
2121
name: "protected-resource",
2222
component: () => import("../views/ProtectedResourceView.vue"),
2323
},
24+
{
25+
path: "/organizations",
26+
name: "organizations",
27+
component: () => import("../views/OrganizationsView.vue"),
28+
},
2429
],
2530
});
2631

‎packages/vue-sample/src/views/HomeView.vue

+2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ if (isAuthenticated.value) {
4646
</tbody>
4747
</table>
4848
<RouterLink to="/protected-resource">View Protected Resource</RouterLink>
49+
<br />
50+
<RouterLink to="/organizations">View Organizations</RouterLink>
4951
</div>
5052
</div>
5153
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script setup lang="ts">
2+
import { useLogto } from "@logto/vue";
3+
import { onMounted, ref } from "vue";
4+
5+
const { getOrganizationToken, getOrganizationTokenClaims, getIdTokenClaims } =
6+
useLogto();
7+
const organizationIds = ref<string[]>();
8+
9+
onMounted(async () => {
10+
const claims = await getIdTokenClaims();
11+
12+
console.log("ID token claims", claims);
13+
organizationIds.value = claims?.organizations;
14+
});
15+
16+
const onClickFetchOrgToken = async (organizationId: string) => {
17+
console.log("raw token", await getOrganizationToken(organizationId));
18+
console.log("claims", await getOrganizationTokenClaims(organizationId));
19+
};
20+
</script>
21+
22+
<template>
23+
<section>
24+
<h2>Organizations</h2>
25+
<RouterLink to="/">Go back</RouterLink>
26+
<p v-if="organizationIds?.length === 0">
27+
No organization memberships found.
28+
</p>
29+
<ul>
30+
<li
31+
v-for="organizationId of organizationIds"
32+
v-bind:key="organizationId"
33+
style="display: flex; align-items: center; gap: 8px"
34+
>
35+
<span>{{ organizationId }}</span>
36+
<button type="button" @click="onClickFetchOrgToken(organizationId)">
37+
fetch token (see console)
38+
</button>
39+
</li>
40+
</ul>
41+
</section>
42+
</template>
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
<script setup lang="ts">
22
import { useLogto } from "@logto/vue";
3-
import { onMounted } from "vue";
4-
import { redirectUrl } from "../consts";
3+
import { onMounted, ref } from "vue";
4+
import { redirectUrl, resource } from "../consts";
55
6-
const { isAuthenticated, isLoading, signIn } = useLogto();
6+
const { isAuthenticated, isLoading, signIn, getAccessToken } = useLogto();
7+
const accessToken = ref<string>();
78
89
onMounted(() => {
910
if (!isAuthenticated.value && !isLoading.value) {
1011
void signIn(redirectUrl);
1112
}
1213
});
14+
15+
const handleClick = async () => {
16+
const token = await getAccessToken(resource);
17+
accessToken.value = token;
18+
};
1319
</script>
1420

1521
<template>
16-
<p v-if="isAuthenticated">
17-
Protected resource is only visible after sign-in.
18-
</p>
22+
<section>
23+
<RouterLink to="/">Go back</RouterLink>
24+
<p v-if="isAuthenticated">
25+
Protected resource is only visible after sign-in.
26+
</p>
27+
<button type="button" @click="handleClick">Get access token</button>
28+
<p v-if="accessToken">
29+
Access token: <code>{{ accessToken }}</code>
30+
</p>
31+
</section>
1932
</template>

‎packages/vue/package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"prepack": "pnpm build && pnpm test"
3232
},
3333
"dependencies": {
34-
"@logto/browser": "workspace:^2.2.1"
34+
"@logto/browser": "workspace:^2.2.1",
35+
"@silverhand/essentials": "^2.6.2"
3536
},
3637
"devDependencies": {
3738
"@silverhand/eslint-config": "^5.0.0",
@@ -46,7 +47,7 @@
4647
"prettier": "^3.0.0",
4748
"stylelint": "^15.0.0",
4849
"typescript": "^5.0.0",
49-
"vue": "^3.2.35"
50+
"vue": "^3.3.13"
5051
},
5152
"peerDependencies": {
5253
"vue": ">=3.0.0"
@@ -58,7 +59,8 @@
5859
"error",
5960
{
6061
"replacements": {
61-
"ref": false
62+
"ref": false,
63+
"args": false
6264
}
6365
}
6466
]

‎packages/vue/src/index.test.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const mockedFetchUserInfo = jest.fn().mockResolvedValue({ sub: 'foo' });
1414
const getAccessToken = jest.fn(() => {
1515
throw new Error('not authenticated');
1616
});
17+
const signIn = jest.fn();
1718
const injectMock = jest.fn<unknown, string[]>((): unknown => {
1819
return undefined;
1920
});
@@ -24,11 +25,17 @@ jest.mock('@logto/browser', () => {
2425
isAuthenticated,
2526
isSignInRedirected,
2627
handleSignInCallback,
28+
getRefreshToken: jest.fn(),
2729
getAccessToken,
30+
getAccessTokenClaims: jest.fn(),
31+
getOrganizationToken: jest.fn(),
32+
getOrganizationTokenClaims: jest.fn(),
33+
getIdToken: jest.fn(),
34+
getIdTokenClaims: jest.fn(),
35+
signIn,
36+
signOut: jest.fn(),
2837
fetchUserInfo: mockedFetchUserInfo,
29-
signIn: jest.fn().mockResolvedValue(undefined),
30-
signOut: jest.fn().mockResolvedValue(undefined),
31-
};
38+
} satisfies Partial<LogtoClient>;
3239
});
3340
});
3441

@@ -95,6 +102,10 @@ describe('useLogto', () => {
95102
signIn,
96103
signOut,
97104
getAccessToken,
105+
getAccessTokenClaims,
106+
getOrganizationToken,
107+
getOrganizationTokenClaims,
108+
getIdToken,
98109
getIdTokenClaims,
99110
fetchUserInfo,
100111
} = useLogto();
@@ -105,6 +116,10 @@ describe('useLogto', () => {
105116
expect(signIn).toBeInstanceOf(Function);
106117
expect(signOut).toBeInstanceOf(Function);
107118
expect(getAccessToken).toBeInstanceOf(Function);
119+
expect(getAccessTokenClaims).toBeInstanceOf(Function);
120+
expect(getOrganizationToken).toBeInstanceOf(Function);
121+
expect(getOrganizationTokenClaims).toBeInstanceOf(Function);
122+
expect(getIdToken).toBeInstanceOf(Function);
108123
expect(getIdTokenClaims).toBeInstanceOf(Function);
109124
expect(fetchUserInfo).toBeInstanceOf(Function);
110125
});

‎packages/vue/src/index.ts

+23-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type { IdTokenClaims, LogtoConfig, UserInfoResponse } from '@logto/browser';
1+
import type { LogtoConfig } from '@logto/browser';
22
import LogtoClient from '@logto/browser';
3+
import { type Optional } from '@silverhand/essentials';
34
import type { App, Ref } from 'vue';
45
import { inject, readonly, watchEffect } from 'vue';
56

@@ -31,6 +32,12 @@ export {
3132
PersistKey,
3233
} from '@logto/browser';
3334

35+
type OptionalPromiseReturn<T> = {
36+
[K in keyof T]: T[K] extends (...args: infer A) => Promise<infer R>
37+
? (...args: A) => Promise<Optional<R>>
38+
: T[K];
39+
};
40+
3441
type LogtoVuePlugin = {
3542
install: (app: App, config: LogtoConfig) => void;
3643
};
@@ -39,12 +46,21 @@ type Logto = {
3946
isAuthenticated: Readonly<Ref<boolean>>;
4047
isLoading: Readonly<Ref<boolean>>;
4148
error: Readonly<Ref<Error | undefined>>;
42-
fetchUserInfo: () => Promise<UserInfoResponse | undefined>;
43-
getAccessToken: (resource?: string) => Promise<string | undefined>;
44-
getIdTokenClaims: () => Promise<IdTokenClaims | undefined>;
45-
signIn: (redirectUri: string) => Promise<void>;
46-
signOut: (postLogoutRedirectUri?: string) => Promise<void>;
47-
};
49+
} & OptionalPromiseReturn<
50+
Pick<
51+
LogtoClient,
52+
| 'getRefreshToken'
53+
| 'getAccessToken'
54+
| 'getAccessTokenClaims'
55+
| 'getOrganizationToken'
56+
| 'getOrganizationTokenClaims'
57+
| 'getIdToken'
58+
| 'getIdTokenClaims'
59+
| 'signIn'
60+
| 'signOut'
61+
| 'fetchUserInfo'
62+
>
63+
>;
4864

4965
/**
5066
* Creates the Logto Vue plugin

‎packages/vue/src/plugin.ts

+35-80
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,46 @@
1-
import type { InteractionMode } from '@logto/browser';
1+
import { type Optional } from '@silverhand/essentials';
22

33
import type { Context } from './context.js';
44
import { throwContextError } from './context.js';
55

66
export const createPluginMethods = (context: Context) => {
77
const { logtoClient, setLoading, setError, setIsAuthenticated } = context;
88

9-
const signIn = async (redirectUri: string, interactionMode?: InteractionMode) => {
10-
if (!logtoClient.value) {
11-
return throwContextError();
12-
}
13-
14-
try {
15-
setLoading(true);
16-
17-
await logtoClient.value.signIn(redirectUri, interactionMode);
18-
} catch (error: unknown) {
19-
setError(error, 'Unexpected error occurred while signing in.');
20-
}
21-
};
22-
23-
const signOut = async (postLogoutRedirectUri?: string) => {
24-
if (!logtoClient.value) {
25-
return throwContextError();
26-
}
27-
28-
try {
29-
setLoading(true);
30-
31-
await logtoClient.value.signOut(postLogoutRedirectUri);
32-
33-
// We deliberately do NOT set isAuthenticated to false here, because the app state may change immediately
34-
// even before navigating to the oidc end session endpoint, which might cause rendering problems.
35-
// Moreover, since the location will be redirected, the isAuthenticated state will not matter any more.
36-
} catch (error: unknown) {
37-
setError(error, 'Unexpected error occurred while signing out.');
38-
} finally {
39-
setLoading(false);
40-
}
41-
};
42-
43-
const fetchUserInfo = async () => {
44-
if (!logtoClient.value) {
45-
return throwContextError();
46-
}
47-
48-
try {
49-
setLoading(true);
50-
51-
return await logtoClient.value.fetchUserInfo();
52-
} catch (error: unknown) {
53-
setError(error, 'Unexpected error occurred while fetching user info.');
54-
} finally {
55-
setLoading(false);
56-
}
57-
};
58-
59-
const getAccessToken = async (resource?: string) => {
60-
if (!logtoClient.value) {
61-
return throwContextError();
62-
}
63-
64-
try {
65-
setLoading(true);
66-
67-
return await logtoClient.value.getAccessToken(resource);
68-
} catch (error: unknown) {
69-
setError(error, 'Unexpected error occurred while getting access token.');
70-
} finally {
71-
setLoading(false);
72-
}
9+
const client = logtoClient.value ?? throwContextError();
10+
11+
const proxy = <R, T extends unknown[]>(
12+
run: (...args: T) => Promise<R>,
13+
resetLoadingState = true
14+
) => {
15+
return async (...args: T): Promise<Optional<R>> => {
16+
try {
17+
setLoading(true);
18+
return await run(...args);
19+
} catch (error: unknown) {
20+
setError(error, `Unexpected error occurred while calling ${run.name}.`);
21+
} finally {
22+
if (resetLoadingState) {
23+
setLoading(false);
24+
}
25+
}
26+
};
7327
};
7428

75-
const getIdTokenClaims = async () => {
76-
if (!logtoClient.value) {
77-
return throwContextError();
78-
}
79-
80-
try {
81-
return await logtoClient.value.getIdTokenClaims();
82-
} catch (error: unknown) {
83-
setError(error, 'Unexpected error occurred while getting id token claims.');
84-
}
29+
const methods = {
30+
getRefreshToken: proxy(client.getRefreshToken.bind(client)),
31+
getAccessToken: proxy(client.getAccessToken.bind(client)),
32+
getAccessTokenClaims: proxy(client.getAccessTokenClaims.bind(client)),
33+
getOrganizationToken: proxy(client.getOrganizationToken.bind(client)),
34+
getOrganizationTokenClaims: proxy(client.getOrganizationTokenClaims.bind(client)),
35+
getIdToken: proxy(client.getIdToken.bind(client)),
36+
getIdTokenClaims: proxy(client.getIdTokenClaims.bind(client)),
37+
signIn: proxy(client.signIn.bind(client), false),
38+
// We deliberately do NOT set isAuthenticated to false in the function below, because the app state
39+
// may change immediately even before navigating to the oidc end session endpoint, which might cause
40+
// rendering problems.
41+
// Moreover, since the location will be redirected, the isAuthenticated state will not matter any more.
42+
signOut: proxy(client.signOut.bind(client)),
43+
fetchUserInfo: proxy(client.fetchUserInfo.bind(client)),
8544
};
8645

8746
const handleSignInCallback = async (callbackUri: string, callbackFunction?: () => void) => {
@@ -102,11 +61,7 @@ export const createPluginMethods = (context: Context) => {
10261
};
10362

10463
return {
105-
signIn,
106-
signOut,
107-
fetchUserInfo,
108-
getAccessToken,
109-
getIdTokenClaims,
64+
...methods,
11065
handleSignInCallback,
11166
};
11267
};

‎pnpm-lock.yaml

+497-294
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.