@thanhphi/create-360-miniapp 0.0.0-stage → 0.2.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/bin/create-360-miniapp.cjs +54 -0
  4. package/lib/create.cjs +56 -0
  5. package/package.json +9 -4
  6. package/template/.eslintrc.cjs +1 -0
  7. package/template/.prettierrc.cjs +1 -0
  8. package/template/.watchmanconfig +1 -0
  9. package/template/App.tsx +2 -0
  10. package/template/README.md +234 -0
  11. package/template/__tests__/auth.test.ts +287 -0
  12. package/template/aliases.cjs +16 -0
  13. package/template/app.config.js +23 -0
  14. package/template/app.json +17 -0
  15. package/template/assets/.gitkeep +0 -0
  16. package/template/babel.config.js +7 -0
  17. package/template/declare/.gitkeep +0 -0
  18. package/template/declare/assets.d.ts +11 -0
  19. package/template/env/beta.json +16 -0
  20. package/template/env/prod.json +16 -0
  21. package/template/env/staging.json +16 -0
  22. package/template/gitignore +19 -0
  23. package/template/index.js +2 -0
  24. package/template/index.repack.js +14 -0
  25. package/template/jest.config.cjs +29 -0
  26. package/template/metro.config.js +6 -0
  27. package/template/package.json +88 -0
  28. package/template/pipelines/beta.yml +91 -0
  29. package/template/pipelines/production.yml +91 -0
  30. package/template/pipelines/staging.yml +91 -0
  31. package/template/pipelines/templates/build-and-zip.yml +89 -0
  32. package/template/pipelines/templates/upload-and-register.yml +89 -0
  33. package/template/react-native.config.js +3 -0
  34. package/template/rspack.config.cjs +53 -0
  35. package/template/scripts/clean-mini-app-output.js +9 -0
  36. package/template/scripts/sync-env.js +29 -0
  37. package/template/src/App.tsx +50 -0
  38. package/template/src/StandaloneApp.tsx +43 -0
  39. package/template/src/assets/icons/.gitkeep +1 -0
  40. package/template/src/assets/images/.gitkeep +1 -0
  41. package/template/src/assets/svg/.gitkeep +1 -0
  42. package/template/src/bootstrap.tsx +7 -0
  43. package/template/src/common/mini-app/provider.tsx +44 -0
  44. package/template/src/common/mini-app/types.ts +13 -0
  45. package/template/src/config/.gitkeep +0 -0
  46. package/template/src/constants/.gitkeep +0 -0
  47. package/template/src/hooks/.gitkeep +0 -0
  48. package/template/src/library/components/.gitkeep +0 -0
  49. package/template/src/library/networking/client.ts +53 -0
  50. package/template/src/library/networking/errors.ts +25 -0
  51. package/template/src/library/utils/responsive.ts +7 -0
  52. package/template/src/library/utils/storage/.gitkeep +0 -0
  53. package/template/src/modules/__MODULE__/assets/.gitkeep +0 -0
  54. package/template/src/modules/__MODULE__/components/ProfileCard.tsx +37 -0
  55. package/template/src/modules/__MODULE__/constants/.gitkeep +0 -0
  56. package/template/src/modules/__MODULE__/navigation/ModuleNavigator.tsx +18 -0
  57. package/template/src/modules/__MODULE__/redux/home-slice.ts +13 -0
  58. package/template/src/modules/__MODULE__/screens/home/hooks/use-home.ts +30 -0
  59. package/template/src/modules/__MODULE__/screens/home/index.tsx +97 -0
  60. package/template/src/modules/__MODULE__/services/profile.service.ts +32 -0
  61. package/template/src/modules/__MODULE__/types/.gitkeep +0 -0
  62. package/template/src/modules/auth/components/.gitkeep +0 -0
  63. package/template/src/modules/auth/constants/.gitkeep +0 -0
  64. package/template/src/modules/auth/hooks/use-auth.ts +55 -0
  65. package/template/src/modules/auth/navigation/AuthenticationNavigator.tsx +16 -0
  66. package/template/src/modules/auth/redux/auth-slice.ts +36 -0
  67. package/template/src/modules/auth/redux/selectors.ts +6 -0
  68. package/template/src/modules/auth/screens/LoginScreen.tsx +75 -0
  69. package/template/src/modules/auth/services/oidc.ts +64 -0
  70. package/template/src/modules/auth/services/standalone-session.ts +121 -0
  71. package/template/src/navigation/MainStack.tsx +46 -0
  72. package/template/src/redux/RootReducers.ts +8 -0
  73. package/template/src/redux/hooks.ts +5 -0
  74. package/template/src/redux/store.ts +10 -0
  75. package/template/src/services/application.ts +87 -0
  76. package/template/src/themes/colors/dark.ts +90 -0
  77. package/template/src/themes/colors/light.ts +90 -0
  78. package/template/src/themes/index.ts +38 -0
  79. package/template/src/themes/standalone.ts +6 -0
  80. package/template/src/types/.gitkeep +0 -0
  81. package/template/tsconfig.json +65 -0
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { QueryClientProvider } from '@tanstack/react-query';
4
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
5
+ import { SafeAreaProvider } from 'react-native-safe-area-context';
6
+ import { UIThemeProvider } from '@thanhphi/ui360';
7
+ import { useUnistyles } from 'react-native-unistyles';
8
+ import type {} from '../../themes';
9
+ import type { ApplicationServices } from '../../services/application';
10
+
11
+ const RuntimeContext = React.createContext<ApplicationServices | null>(null);
12
+
13
+ export function useApplicationServices() {
14
+ const value = React.use(RuntimeContext);
15
+ if (!value) throw new Error('ApplicationProvider is required.');
16
+ return value;
17
+ }
18
+
19
+ export function ApplicationProvider({
20
+ services,
21
+ children,
22
+ }: React.PropsWithChildren<{ services: ApplicationServices }>) {
23
+ const {
24
+ theme: { type, color, spacing, radius, typography },
25
+ } = useUnistyles();
26
+ // Read active tokens here so Unistyles tracks changes; never rebuild defaults.
27
+ const uiTheme = React.useMemo(
28
+ () => ({ mode: type, colors: color, spacing, radius, typography }),
29
+ [type, color, spacing, radius, typography],
30
+ );
31
+ return (
32
+ <GestureHandlerRootView style={{ flex: 1 }}>
33
+ <SafeAreaProvider>
34
+ <RuntimeContext value={services}>
35
+ <Provider store={services.store}>
36
+ <QueryClientProvider client={services.queryClient}>
37
+ <UIThemeProvider theme={uiTheme}>{children}</UIThemeProvider>
38
+ </QueryClientProvider>
39
+ </Provider>
40
+ </RuntimeContext>
41
+ </SafeAreaProvider>
42
+ </GestureHandlerRootView>
43
+ );
44
+ }
@@ -0,0 +1,13 @@
1
+ import type { HostCapabilities } from '@thanhphi/miniapp-sdk';
2
+
3
+ export interface MiniAppProps {
4
+ /** Required hosted contract. Identity/tokens/refresh come from auth capabilities. */
5
+ hostCapabilities: HostCapabilities;
6
+ param_supper_app?: { notifications?: Record<string, unknown> };
7
+ }
8
+
9
+ export interface AuthActions {
10
+ isConfigured: boolean;
11
+ signIn(): Promise<void>;
12
+ signOut(): void;
13
+ }
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,53 @@
1
+ import {
2
+ AuthError,
3
+ createHttpClient,
4
+ type AuthCapabilities,
5
+ type HttpRequestOptions,
6
+ } from '@thanhphi/miniapp-sdk';
7
+ import config from '@env-config';
8
+
9
+ export function createApiClient(
10
+ auth: AuthCapabilities,
11
+ reportAuthError: (error: AuthError, sessionId: string | null) => void,
12
+ ) {
13
+ // Empty configuration must remain reviewable; no request is made until configured.
14
+ const http = config.BASE_URL.trim()
15
+ ? createHttpClient({
16
+ baseURL: config.BASE_URL,
17
+ timeoutMs: config.API_TIMEOUT_MS,
18
+ auth,
19
+ })
20
+ : null;
21
+ async function request<T>(
22
+ path: string,
23
+ options: HttpRequestOptions = {},
24
+ ): Promise<T> {
25
+ const before = auth.getSnapshot();
26
+ if (!before.sessionId || !before.accessToken)
27
+ throw new AuthError('expired');
28
+ if (!http)
29
+ throw new Error('Hãy cấu hình BASE_URL trong env trước khi gọi API.');
30
+ try {
31
+ const response = await http.request<T>(path, { ...options, auth: true });
32
+ return response.data;
33
+ } catch (error) {
34
+ if (error instanceof AuthError) reportAuthError(error, before.sessionId);
35
+ throw error;
36
+ }
37
+ }
38
+ return {
39
+ request,
40
+ get: <T>(path: string, options?: HttpRequestOptions) =>
41
+ request<T>(path, { ...options, method: 'GET' }),
42
+ post: <T>(path: string, options?: HttpRequestOptions) =>
43
+ request<T>(path, { ...options, method: 'POST' }),
44
+ put: <T>(path: string, options?: HttpRequestOptions) =>
45
+ request<T>(path, { ...options, method: 'PUT' }),
46
+ patch: <T>(path: string, options?: HttpRequestOptions) =>
47
+ request<T>(path, { ...options, method: 'PATCH' }),
48
+ delete: <T>(path: string, options?: HttpRequestOptions) =>
49
+ request<T>(path, { ...options, method: 'DELETE' }),
50
+ dispose: () => http?.dispose(),
51
+ };
52
+ }
53
+ export type ApiClient = ReturnType<typeof createApiClient>;
@@ -0,0 +1,25 @@
1
+ import { AuthError, HttpError } from '@thanhphi/miniapp-sdk';
2
+
3
+ export function errorMessage(error: unknown): string {
4
+ if (error instanceof AuthError) {
5
+ if (error.kind === 'session-changed')
6
+ return 'Tài khoản đã thay đổi. Hãy tải lại dữ liệu.';
7
+ if (error.kind === 'expired')
8
+ return 'Phiên đăng nhập đã hết hạn. Hãy đăng nhập lại.';
9
+ return 'Không thể làm mới phiên. Hãy thử lại hoặc mở lại mini app.';
10
+ }
11
+ if (error instanceof HttpError) {
12
+ if (error.kind === 'timeout')
13
+ return 'Yêu cầu quá thời gian chờ. Hãy thử lại.';
14
+ if (error.kind === 'cancelled' || error.kind === 'disposed')
15
+ return 'Yêu cầu đã được huỷ.';
16
+ if (error.kind === 'http')
17
+ return `Máy chủ trả về lỗi ${error.status ?? ''}. Hãy thử lại.`;
18
+ if (error.kind === 'invalid-response')
19
+ return 'Dữ liệu trả về không đúng định dạng JSON.';
20
+ return 'Không thể kết nối máy chủ. Kiểm tra kết nối mạng rồi thử lại.';
21
+ }
22
+ return error instanceof Error
23
+ ? error.message
24
+ : 'Không thể hoàn tất yêu cầu. Hãy thử lại.';
25
+ }
@@ -0,0 +1,7 @@
1
+ import { Dimensions } from 'react-native';
2
+
3
+ const DESIGN_WIDTH = 375;
4
+ export const DEVICE_WIDTH = Dimensions.get('window').width;
5
+
6
+ /** Use for exceptional app geometry only; theme tokens are already scaled. */
7
+ export const ratioW = (value: number) => (value * DEVICE_WIDTH) / DESIGN_WIDTH;
File without changes
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { UIText } from '@thanhphi/ui360';
3
+ import { View } from 'react-native';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import type {} from '../../../themes';
6
+ import type { Profile } from '../services/profile.service';
7
+
8
+ export function ProfileCard({ profile }: { profile: Profile }) {
9
+ return (
10
+ <View style={styles.card}>
11
+ <UIText selectable variant="title" weight="600">
12
+ {profile.name || 'Tài khoản'}
13
+ </UIText>
14
+ {profile.email && (
15
+ <UIText selectable color="content2">
16
+ {profile.email}
17
+ </UIText>
18
+ )}
19
+ {profile.id && (
20
+ <UIText selectable color="content2">
21
+ Mã: {profile.id}
22
+ </UIText>
23
+ )}
24
+ </View>
25
+ );
26
+ }
27
+
28
+ // Same guideline names as shared components; no per-component style hook.
29
+ const styles = StyleSheet.create(({ color, spacing, radius }) => ({
30
+ card: {
31
+ padding: spacing.xxl,
32
+ gap: spacing.sm,
33
+ borderRadius: radius.lg,
34
+ borderCurve: 'continuous',
35
+ backgroundColor: color.background,
36
+ },
37
+ }));
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import { createNativeStackNavigator } from '@react-navigation/native-stack';
3
+ import HomeScreen from '../screens/home';
4
+
5
+ export type ModuleStackParams = { Home: undefined };
6
+ const Stack = createNativeStackNavigator<ModuleStackParams>();
7
+
8
+ export default function ModuleNavigator() {
9
+ return (
10
+ <Stack.Navigator>
11
+ <Stack.Screen
12
+ name="Home"
13
+ component={HomeScreen}
14
+ options={{ title: '__DISPLAY_NAME__' }}
15
+ />
16
+ </Stack.Navigator>
17
+ );
18
+ }
@@ -0,0 +1,13 @@
1
+ import { createSlice } from '@reduxjs/toolkit';
2
+
3
+ const homeSlice = createSlice({
4
+ name: '__MODULE__',
5
+ initialState: { profileRequested: false },
6
+ reducers: {
7
+ profileRequested(state) {
8
+ state.profileRequested = true;
9
+ },
10
+ },
11
+ });
12
+ export const { profileRequested } = homeSlice.actions;
13
+ export default homeSlice.reducer;
@@ -0,0 +1,30 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { useApplicationServices } from '../../../../../common/mini-app/provider';
3
+ import { useAppDispatch, useAppSelector } from '../../../../../redux/hooks';
4
+ import {
5
+ selectIsAuthenticated,
6
+ selectSessionId,
7
+ } from '../../../../auth/redux/selectors';
8
+ import { profileRequested } from '../../../redux/home-slice';
9
+ import { getProfile } from '../../../services/profile.service';
10
+
11
+ export function useHome() {
12
+ const { api } = useApplicationServices();
13
+ const dispatch = useAppDispatch();
14
+ const sessionId = useAppSelector(selectSessionId);
15
+ const authenticated = useAppSelector(selectIsAuthenticated);
16
+ const requested = useAppSelector(state => state.home.profileRequested);
17
+ const query = useQuery({
18
+ queryKey: ['__MODULE__', 'profile', sessionId],
19
+ queryFn: ({ signal }) => getProfile(api, signal),
20
+ enabled: authenticated && requested,
21
+ });
22
+ return {
23
+ ...query,
24
+ requested,
25
+ loadProfile() {
26
+ if (requested) void query.refetch();
27
+ else dispatch(profileRequested());
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,97 @@
1
+ import React from 'react';
2
+ import { Button, UIText } from '@thanhphi/ui360';
3
+ import { ActivityIndicator, ScrollView, View } from 'react-native';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { StyleSheet, useUnistyles } from 'react-native-unistyles';
6
+ import { errorMessage } from '../../../../library/networking/errors';
7
+ import type {} from '../../../../themes';
8
+ import { useAuth } from '../../../auth/hooks/use-auth';
9
+ import { ProfileCard } from '../../components/ProfileCard';
10
+ import { useHome } from './hooks/use-home';
11
+
12
+ export default function HomeScreen() {
13
+ const {
14
+ theme: { color, spacing },
15
+ } = useUnistyles();
16
+ const query = useHome();
17
+ const auth = useAuth();
18
+ const insets = useSafeAreaInsets();
19
+ return (
20
+ <ScrollView
21
+ contentInsetAdjustmentBehavior="automatic"
22
+ contentContainerStyle={[
23
+ styles.content,
24
+ { paddingBottom: insets.bottom + spacing.xxl },
25
+ ]}
26
+ >
27
+ {!query.requested && (
28
+ <View style={styles.intro}>
29
+ <UIText variant="title" weight="600" accessibilityRole="header">
30
+ Phiên đăng nhập đã sẵn sàng
31
+ </UIText>
32
+ <UIText color="content2">
33
+ Tải thông tin tài khoản khi bạn muốn kiểm tra kết nối.
34
+ </UIText>
35
+ </View>
36
+ )}
37
+ {query.isLoading && (
38
+ <ActivityIndicator
39
+ size="large"
40
+ color={color.primary500}
41
+ accessibilityLabel="Đang tải hồ sơ"
42
+ />
43
+ )}
44
+ {query.fetchStatus === 'paused' && (
45
+ <UIText selectable color="content2">
46
+ Kết nối đang tạm dừng. Thử lại khi có mạng.
47
+ </UIText>
48
+ )}
49
+ {query.error && (
50
+ <UIText selectable accessibilityRole="alert" color="danger">
51
+ {errorMessage(query.error)}
52
+ </UIText>
53
+ )}
54
+ {auth.message && (
55
+ <UIText selectable accessibilityRole="alert" color="danger">
56
+ {auth.message}
57
+ </UIText>
58
+ )}
59
+ {query.data === null && (
60
+ <UIText color="content2">
61
+ Chưa có thông tin tài khoản. Bạn có thể thử tải lại.
62
+ </UIText>
63
+ )}
64
+ {query.data && <ProfileCard profile={query.data} />}
65
+ <Button
66
+ label={
67
+ query.isFetching
68
+ ? 'Đang tải…'
69
+ : query.error
70
+ ? 'Thử lại'
71
+ : query.requested
72
+ ? 'Tải lại hồ sơ'
73
+ : 'Tải hồ sơ'
74
+ }
75
+ loading={query.isFetching}
76
+ onPress={query.loadProfile}
77
+ />
78
+ <Button
79
+ label={auth.isStandalone ? 'Đăng xuất khỏi ứng dụng' : 'Quay lại MySGR'}
80
+ onPress={() => {
81
+ if (auth.isStandalone) auth.signOut();
82
+ else void auth.closeMiniApp();
83
+ }}
84
+ />
85
+ </ScrollView>
86
+ );
87
+ }
88
+
89
+ const styles = StyleSheet.create(({ color, spacing }) => ({
90
+ content: {
91
+ padding: spacing.xxl,
92
+ gap: spacing.xxl,
93
+ flexGrow: 1,
94
+ backgroundColor: color.base3,
95
+ },
96
+ intro: { gap: spacing.lg },
97
+ }));
@@ -0,0 +1,32 @@
1
+ import type { ApiClient } from '../../../library/networking/client';
2
+
3
+ export interface Profile {
4
+ id?: string;
5
+ name?: string;
6
+ email?: string;
7
+ }
8
+
9
+ /** Adapt this relative path and response shape to your backend before using it. */
10
+ export const PROFILE_PATH = '/me';
11
+
12
+ export async function getProfile(
13
+ api: ApiClient,
14
+ signal: AbortSignal,
15
+ ): Promise<Profile | null> {
16
+ const data = await api.get<unknown>(PROFILE_PATH, { signal });
17
+ if (data === null) return null;
18
+ if (typeof data !== 'object' || Array.isArray(data)) {
19
+ throw new Error(
20
+ 'API hồ sơ cần trả về object hoặc null. Hãy cập nhật profile.service.ts theo backend.',
21
+ );
22
+ }
23
+ const value = data as Record<string, unknown>;
24
+ const stringValue = (key: string) =>
25
+ typeof value[key] === 'string' ? (value[key] as string) : undefined;
26
+ const profile = {
27
+ id: stringValue('id'),
28
+ name: stringValue('name'),
29
+ email: stringValue('email'),
30
+ };
31
+ return profile.id || profile.name || profile.email ? profile : null;
32
+ }
File without changes
File without changes
File without changes
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { useApplicationServices } from '../../../common/mini-app/provider';
3
+ import { useAppSelector } from '../../../redux/hooks';
4
+ import {
5
+ selectAuthMessage,
6
+ selectIsAuthenticated,
7
+ selectSessionId,
8
+ } from '../redux/selectors';
9
+ import { errorMessage } from '../../../library/networking/errors';
10
+
11
+ export function useAuth() {
12
+ const { authActions, runtime } = useApplicationServices();
13
+ const authenticated = useAppSelector(selectIsAuthenticated);
14
+ const sessionId = useAppSelector(selectSessionId);
15
+ const sessionMessage = useAppSelector(selectAuthMessage);
16
+ const [pending, setPending] = React.useState(false);
17
+ const [message, setMessage] = React.useState<string | null>(null);
18
+ const mounted = React.useRef(true);
19
+ React.useEffect(() => {
20
+ mounted.current = true;
21
+ return () => {
22
+ mounted.current = false;
23
+ };
24
+ }, []);
25
+
26
+ const signIn = async () => {
27
+ if (!authActions || pending) return;
28
+ setPending(true);
29
+ setMessage(null);
30
+ try {
31
+ await authActions.signIn();
32
+ } catch (error) {
33
+ if (mounted.current) setMessage(errorMessage(error));
34
+ } finally {
35
+ if (mounted.current) setPending(false);
36
+ }
37
+ };
38
+ return {
39
+ authenticated,
40
+ sessionId,
41
+ message: message ?? sessionMessage,
42
+ pending,
43
+ isStandalone: Boolean(authActions),
44
+ canSignIn: Boolean(authActions?.isConfigured),
45
+ signIn,
46
+ signOut: () => authActions?.signOut(),
47
+ async closeMiniApp() {
48
+ try {
49
+ await runtime.capabilities.closeMiniApp();
50
+ } catch (error) {
51
+ if (mounted.current) setMessage(errorMessage(error));
52
+ }
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import { createNativeStackNavigator } from '@react-navigation/native-stack';
3
+ import LoginScreen from '../screens/LoginScreen';
4
+
5
+ const Stack = createNativeStackNavigator<{ Login: undefined }>();
6
+ export default function AuthenticationNavigator() {
7
+ return (
8
+ <Stack.Navigator>
9
+ <Stack.Screen
10
+ name="Login"
11
+ component={LoginScreen}
12
+ options={{ title: '__DISPLAY_NAME__' }}
13
+ />
14
+ </Stack.Navigator>
15
+ );
16
+ }
@@ -0,0 +1,36 @@
1
+ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2
+
3
+ export interface AuthState {
4
+ sessionId: string | null;
5
+ authenticated: boolean;
6
+ message: string | null;
7
+ }
8
+ const initialState: AuthState = {
9
+ sessionId: null,
10
+ authenticated: false,
11
+ message: null,
12
+ };
13
+
14
+ const authSlice = createSlice({
15
+ name: 'auth',
16
+ initialState,
17
+ reducers: {
18
+ sessionReceived(
19
+ state,
20
+ {
21
+ payload,
22
+ }: PayloadAction<Pick<AuthState, 'sessionId' | 'authenticated'>>,
23
+ ) {
24
+ state.sessionId = payload.sessionId;
25
+ state.authenticated = payload.authenticated;
26
+ state.message = null;
27
+ },
28
+ authenticationFailed(state, { payload }: PayloadAction<string>) {
29
+ state.authenticated = false;
30
+ state.message = payload;
31
+ },
32
+ },
33
+ });
34
+
35
+ export const { sessionReceived, authenticationFailed } = authSlice.actions;
36
+ export default authSlice.reducer;
@@ -0,0 +1,6 @@
1
+ import type { RootState } from '../../../redux/store';
2
+
3
+ export const selectIsAuthenticated = (state: RootState) =>
4
+ state.auth.authenticated;
5
+ export const selectSessionId = (state: RootState) => state.auth.sessionId;
6
+ export const selectAuthMessage = (state: RootState) => state.auth.message;
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { Button, UIText } from '@thanhphi/ui360';
3
+ import { ActivityIndicator, ScrollView, View } from 'react-native';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { StyleSheet, useUnistyles } from 'react-native-unistyles';
6
+ import type {} from '../../../themes';
7
+ import { useAuth } from '../hooks/use-auth';
8
+
9
+ export default function LoginScreen() {
10
+ const {
11
+ theme: { color, spacing },
12
+ } = useUnistyles();
13
+ const auth = useAuth();
14
+ const insets = useSafeAreaInsets();
15
+ return (
16
+ <ScrollView
17
+ contentInsetAdjustmentBehavior="automatic"
18
+ contentContainerStyle={[
19
+ styles.content,
20
+ { paddingBottom: insets.bottom + spacing.xxl },
21
+ ]}
22
+ >
23
+ <View style={styles.intro}>
24
+ <UIText variant="title" weight="600" accessibilityRole="header">
25
+ Đăng nhập để tiếp tục
26
+ </UIText>
27
+ <UIText color="content2">
28
+ {auth.isStandalone
29
+ ? auth.canSignIn
30
+ ? 'Đăng nhập bằng tài khoản được cấu hình cho ứng dụng.'
31
+ : 'Cấu hình issuer, clientId và redirectUrl trong env, rồi tạo lại native app để sử dụng OIDC.'
32
+ : 'Mini app cần phiên đăng nhập từ MySGR. Host phải hỗ trợ và truyền hostCapabilities theo contract SDK hiện tại.'}
33
+ </UIText>
34
+ </View>
35
+ {auth.message && (
36
+ <UIText selectable accessibilityRole="alert" color="danger">
37
+ {auth.message}
38
+ </UIText>
39
+ )}
40
+ {auth.pending && (
41
+ <ActivityIndicator
42
+ accessibilityLabel="Đang đăng nhập"
43
+ color={color.primary500}
44
+ />
45
+ )}
46
+ {auth.isStandalone ? (
47
+ <Button
48
+ label={auth.pending ? 'Đang đăng nhập…' : 'Đăng nhập'}
49
+ disabled={!auth.canSignIn}
50
+ loading={auth.pending}
51
+ onPress={() => {
52
+ void auth.signIn();
53
+ }}
54
+ />
55
+ ) : (
56
+ <Button
57
+ label="Quay lại MySGR"
58
+ onPress={() => {
59
+ void auth.closeMiniApp();
60
+ }}
61
+ />
62
+ )}
63
+ </ScrollView>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create(({ color, spacing }) => ({
68
+ content: {
69
+ padding: spacing.xxl,
70
+ gap: spacing.xxl,
71
+ flexGrow: 1,
72
+ backgroundColor: color.base3,
73
+ },
74
+ intro: { gap: spacing.lg },
75
+ }));
@@ -0,0 +1,64 @@
1
+ import {
2
+ authorize,
3
+ refresh,
4
+ type AuthConfiguration,
5
+ } from 'react-native-app-auth';
6
+ import { AuthError } from '@thanhphi/miniapp-sdk';
7
+ import config from '@env-config';
8
+ import type { OidcOperations } from './standalone-session';
9
+
10
+ const authConfig: AuthConfiguration = {
11
+ issuer: config.AUTH.issuer,
12
+ clientId: config.AUTH.clientId,
13
+ redirectUrl: config.AUTH.redirectUrl,
14
+ scopes: [...config.AUTH.scopes],
15
+ };
16
+
17
+ export function isOidcConfigured(value: {
18
+ issuer: string;
19
+ clientId: string;
20
+ redirectUrl: string;
21
+ scopes: readonly string[];
22
+ }): boolean {
23
+ const filled = (field: string) =>
24
+ Boolean(field.trim()) && !field.includes('YOUR_');
25
+ return (
26
+ filled(value.issuer) &&
27
+ filled(value.clientId) &&
28
+ filled(value.redirectUrl) &&
29
+ value.scopes.length > 0 &&
30
+ value.scopes.every(filled)
31
+ );
32
+ }
33
+
34
+ export const oidcOperations: OidcOperations = {
35
+ isConfigured: isOidcConfigured(config.AUTH),
36
+ async authorize() {
37
+ const result = await authorize(authConfig);
38
+ return {
39
+ accessToken: result.accessToken,
40
+ refreshToken: result.refreshToken || undefined,
41
+ };
42
+ },
43
+ async refresh(refreshToken) {
44
+ try {
45
+ const result = await refresh(authConfig, { refreshToken });
46
+ return {
47
+ accessToken: result.accessToken,
48
+ refreshToken: result.refreshToken || undefined,
49
+ };
50
+ } catch (error) {
51
+ if (error instanceof AuthError) throw error;
52
+ // AppAuth's documented OAuth token error for a revoked/expired refresh grant.
53
+ const code =
54
+ typeof error === 'object' && error !== null && 'code' in error
55
+ ? error.code
56
+ : undefined;
57
+ throw new AuthError(
58
+ code === 'invalid_grant' ? 'expired' : 'unavailable',
59
+ undefined,
60
+ error,
61
+ );
62
+ }
63
+ },
64
+ };