Skip to content

Commit 0c0d9e6

Browse files
authored
Merge pull request #276 from logto-io/charles-log-2569-create-logto-vue-sdk
feat(vue): create vue sdk
2 parents b10eba4 + 6a68267 commit 0c0d9e6

10 files changed

+695
-0
lines changed

packages/vue/jest.config.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Config } from '@jest/types';
2+
3+
const config: Config.InitialOptions = {
4+
preset: 'ts-jest',
5+
collectCoverageFrom: ['src/**/*.ts'],
6+
coverageReporters: ['lcov', 'text-summary'],
7+
testEnvironment: 'jsdom',
8+
};
9+
10+
export default config;

packages/vue/package.json

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"name": "@logto/vue",
3+
"version": "0.1.7",
4+
"main": "./lib/index.js",
5+
"exports": "./lib/index.js",
6+
"typings": "./lib/index.d.ts",
7+
"files": [
8+
"lib"
9+
],
10+
"license": "MIT",
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/logto-io/js.git",
14+
"directory": "packages/vue"
15+
},
16+
"scripts": {
17+
"dev:tsc": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
18+
"preinstall": "npx only-allow pnpm",
19+
"precommit": "lint-staged",
20+
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
21+
"lint": "eslint --ext .ts src",
22+
"test": "jest",
23+
"test:coverage": "jest --silent --coverage",
24+
"prepack": "pnpm test"
25+
},
26+
"dependencies": {
27+
"@logto/browser": "^0.1.7"
28+
},
29+
"devDependencies": {
30+
"@jest/types": "^27.5.1",
31+
"@silverhand/eslint-config": "^0.14.0",
32+
"@silverhand/ts-config": "^0.14.0",
33+
"@types/jest": "^27.4.1",
34+
"eslint": "^8.9.0",
35+
"jest": "^27.5.1",
36+
"lint-staged": "^12.3.4",
37+
"postcss": "^8.4.6",
38+
"prettier": "^2.5.1",
39+
"stylelint": "^14.8.2",
40+
"ts-jest": "^27.0.4",
41+
"typescript": "^4.6.2",
42+
"vue": "^3.2.35"
43+
},
44+
"peerDependencies": {
45+
"vue": ">=3.0.0"
46+
},
47+
"eslintConfig": {
48+
"extends": "@silverhand",
49+
"rules": {
50+
"unicorn/prevent-abbreviations": ["error", { "replacements": { "ref": false }}]
51+
}
52+
},
53+
"prettier": "@silverhand/eslint-config/.prettierrc",
54+
"publishConfig": {
55+
"access": "public"
56+
}
57+
}

packages/vue/src/consts.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const logtoInjectionKey = '@logto/vue';
2+
export const contextInjectionKey = '@logto/vue:context';

packages/vue/src/context.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import LogtoClient from '@logto/browser';
2+
import { computed, ComputedRef, reactive, Ref, toRefs, UnwrapRef } from 'vue';
3+
4+
type LogtoContextProperties = {
5+
logtoClient: LogtoClient | undefined;
6+
isAuthenticated: boolean;
7+
loadingCount: number;
8+
error: Error | undefined;
9+
};
10+
11+
export type Context = {
12+
// Wrong type workaround. https://github.com/vuejs/core/issues/2981
13+
logtoClient: Ref<UnwrapRef<LogtoClient | undefined>>;
14+
isAuthenticated: Ref<boolean>;
15+
loadingCount: Ref<number>;
16+
error: Ref<Error | undefined>;
17+
isLoading: ComputedRef<boolean>;
18+
setError: (error: unknown, fallbackErrorMessage?: string | undefined) => void;
19+
setIsAuthenticated: (isAuthenticated: boolean) => void;
20+
setLoading: (isLoading: boolean) => void;
21+
};
22+
23+
export const createContext = (client: LogtoClient): Context => {
24+
const context = toRefs(
25+
reactive<LogtoContextProperties>({
26+
logtoClient: client,
27+
isAuthenticated: client.isAuthenticated,
28+
loadingCount: 0,
29+
error: undefined,
30+
})
31+
);
32+
33+
const { isAuthenticated, loadingCount, error } = context;
34+
35+
const isLoading = computed(() => loadingCount.value > 0);
36+
37+
/* eslint-disable @silverhand/fp/no-mutation */
38+
const setError = (_error: unknown, fallbackErrorMessage?: string) => {
39+
if (_error instanceof Error) {
40+
error.value = _error;
41+
} else if (fallbackErrorMessage) {
42+
error.value = new Error(fallbackErrorMessage);
43+
}
44+
};
45+
46+
const setLoading = (isLoading: boolean) => {
47+
if (isLoading) {
48+
loadingCount.value += 1;
49+
} else {
50+
loadingCount.value = Math.max(0, loadingCount.value - 1);
51+
}
52+
};
53+
54+
const setIsAuthenticated = (_isAuthenticated: boolean) => {
55+
isAuthenticated.value = _isAuthenticated;
56+
};
57+
/* eslint-enable @silverhand/fp/no-mutation */
58+
59+
return { ...context, isLoading, setError, setLoading, setIsAuthenticated };
60+
};
61+
62+
export const throwContextError = (): never => {
63+
throw new Error('Must install Logto plugin first.');
64+
};

packages/vue/src/index.test.ts

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import LogtoClient from '@logto/browser';
2+
import { App, readonly } from 'vue';
3+
4+
import { useLogto, useHandleSignInCallback, createLogto } from '.';
5+
import { contextInjectionKey, logtoInjectionKey } from './consts';
6+
import { createContext } from './context';
7+
import { createPluginMethods } from './plugin';
8+
9+
const isSignInRedirected = jest.fn(() => false);
10+
const handleSignInCallback = jest.fn(async () => Promise.resolve());
11+
const getAccessToken = jest.fn(() => {
12+
throw new Error('not authenticated');
13+
});
14+
const injectMock = jest.fn<any, string[]>((): any => {
15+
return undefined;
16+
});
17+
18+
jest.mock('@logto/browser', () => {
19+
return jest.fn().mockImplementation(() => {
20+
return {
21+
isAuthenticated: false,
22+
isSignInRedirected,
23+
handleSignInCallback,
24+
getAccessToken,
25+
signIn: jest.fn(async () => Promise.resolve()),
26+
signOut: jest.fn(async () => Promise.resolve()),
27+
};
28+
});
29+
});
30+
31+
jest.mock('vue', () => {
32+
return {
33+
...jest.requireActual('vue'),
34+
inject: (key: string) => {
35+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
36+
return injectMock(key);
37+
},
38+
};
39+
});
40+
41+
const appId = 'foo';
42+
const endpoint = 'https://endpoint.com';
43+
44+
const appMock = {
45+
provide: jest.fn(),
46+
} as any as App;
47+
48+
describe('createLogto.install', () => {
49+
test('should call LogtoClient constructor and provide Logto context data', async () => {
50+
createLogto.install(appMock, { appId, endpoint });
51+
52+
expect(LogtoClient).toHaveBeenCalledWith({ endpoint, appId });
53+
expect(appMock.provide).toBeCalled();
54+
});
55+
});
56+
57+
describe('Logto plugin not installed', () => {
58+
test('should throw error if calling `useLogto` before install', () => {
59+
expect(() => {
60+
useLogto();
61+
}).toThrowError('Must install Logto plugin first.');
62+
});
63+
64+
test('should throw error if calling `useHandleSignInCallback` before install', () => {
65+
expect(() => {
66+
useHandleSignInCallback();
67+
}).toThrowError('Must install Logto plugin first.');
68+
});
69+
});
70+
71+
describe('useLogto', () => {
72+
beforeEach(() => {
73+
const client = new LogtoClient({ appId, endpoint });
74+
const context = createContext(client);
75+
const { isAuthenticated, isLoading, error } = context;
76+
77+
injectMock.mockImplementationOnce(() => {
78+
return {
79+
isAuthenticated: readonly(isAuthenticated),
80+
isLoading: readonly(isLoading),
81+
error: readonly(error),
82+
...createPluginMethods(context),
83+
};
84+
});
85+
});
86+
87+
test('should inject Logto context data', () => {
88+
const {
89+
isAuthenticated,
90+
isLoading,
91+
error,
92+
signIn,
93+
signOut,
94+
getAccessToken,
95+
getIdTokenClaims,
96+
fetchUserInfo,
97+
} = useLogto();
98+
99+
expect(isAuthenticated.value).toBe(false);
100+
expect(isLoading.value).toBe(false);
101+
expect(error?.value).toBeUndefined();
102+
expect(signIn).toBeInstanceOf(Function);
103+
expect(signOut).toBeInstanceOf(Function);
104+
expect(getAccessToken).toBeInstanceOf(Function);
105+
expect(getIdTokenClaims).toBeInstanceOf(Function);
106+
expect(fetchUserInfo).toBeInstanceOf(Function);
107+
});
108+
109+
test('should return error when getAccessToken fails', async () => {
110+
const client = new LogtoClient({ appId, endpoint });
111+
const context = createContext(client);
112+
const { getAccessToken } = createPluginMethods(context);
113+
const { error } = context;
114+
115+
await getAccessToken();
116+
expect(error.value).not.toBeUndefined();
117+
expect(error.value?.message).toBe('not authenticated');
118+
});
119+
});
120+
121+
describe('useHandleSignInCallback', () => {
122+
beforeEach(() => {
123+
const client = new LogtoClient({ appId, endpoint });
124+
const context = createContext(client);
125+
126+
injectMock.mockImplementation((key: string) => {
127+
if (key === contextInjectionKey) {
128+
return context;
129+
}
130+
131+
if (key === logtoInjectionKey) {
132+
const { isAuthenticated, isLoading, error } = context;
133+
134+
return {
135+
isAuthenticated: readonly(isAuthenticated),
136+
isLoading: readonly(isLoading),
137+
error: readonly(error),
138+
...createPluginMethods(context),
139+
};
140+
}
141+
});
142+
});
143+
144+
test('not in callback url should not call `handleSignInCallback`', async () => {
145+
const { signIn } = useLogto();
146+
useHandleSignInCallback();
147+
148+
await signIn('https://example.com');
149+
expect(handleSignInCallback).not.toHaveBeenCalled();
150+
});
151+
152+
test('in callback url should call `handleSignInCallback`', async () => {
153+
isSignInRedirected.mockImplementationOnce(() => true);
154+
const { signIn } = useLogto();
155+
useHandleSignInCallback();
156+
157+
await signIn('https://example.com');
158+
159+
expect(handleSignInCallback).toHaveBeenCalledTimes(1);
160+
});
161+
});

0 commit comments

Comments
 (0)