@qlover/create-app 0.7.9 → 0.7.10

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 (75) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/index.cjs +3 -3
  3. package/dist/index.js +3 -3
  4. package/dist/templates/next-app/.env.template +8 -4
  5. package/dist/templates/next-app/config/IOCIdentifier.ts +4 -1
  6. package/dist/templates/next-app/config/Identifier/api.ts +20 -0
  7. package/dist/templates/next-app/config/Identifier/index.ts +2 -0
  8. package/dist/templates/next-app/config/Identifier/page.login.ts +2 -2
  9. package/dist/templates/next-app/config/Identifier/page.register.ts +43 -22
  10. package/dist/templates/next-app/config/Identifier/validator.ts +34 -0
  11. package/dist/templates/next-app/config/i18n/index.ts +1 -0
  12. package/dist/templates/next-app/config/i18n/register18n.ts +44 -0
  13. package/dist/templates/next-app/migrations/schema/UserSchema.ts +13 -0
  14. package/dist/templates/next-app/migrations/sql/1694244000000.sql +10 -0
  15. package/dist/templates/next-app/package.json +11 -2
  16. package/dist/templates/next-app/public/locales/en.json +16 -2
  17. package/dist/templates/next-app/public/locales/zh.json +16 -2
  18. package/dist/templates/next-app/src/app/[locale]/admin/layout.tsx +21 -0
  19. package/dist/templates/next-app/src/app/[locale]/admin/page.tsx +10 -0
  20. package/dist/templates/next-app/src/app/[locale]/login/LoginForm.tsx +25 -5
  21. package/dist/templates/next-app/src/app/[locale]/login/page.tsx +6 -4
  22. package/dist/templates/next-app/src/app/[locale]/page.tsx +3 -3
  23. package/dist/templates/next-app/src/app/[locale]/register/RegisterForm.tsx +176 -0
  24. package/dist/templates/next-app/src/app/[locale]/register/page.tsx +79 -0
  25. package/dist/templates/next-app/src/app/api/user/login/route.ts +50 -0
  26. package/dist/templates/next-app/src/app/api/user/logout/route.ts +27 -0
  27. package/dist/templates/next-app/src/app/api/user/register/route.ts +50 -0
  28. package/dist/templates/next-app/src/base/cases/AppConfig.ts +19 -0
  29. package/dist/templates/next-app/src/base/cases/DialogErrorPlugin.ts +35 -0
  30. package/dist/templates/next-app/src/base/cases/RequestEncryptPlugin.ts +70 -0
  31. package/dist/templates/next-app/src/base/cases/RouterService.ts +4 -0
  32. package/dist/templates/next-app/src/base/cases/StringEncryptor.ts +67 -0
  33. package/dist/templates/next-app/src/base/cases/UserServiceApi.ts +48 -0
  34. package/dist/templates/next-app/src/base/port/AppApiInterface.ts +14 -0
  35. package/dist/templates/next-app/src/base/port/AppUserApiInterface.ts +15 -0
  36. package/dist/templates/next-app/src/base/port/DBBridgeInterface.ts +18 -0
  37. package/dist/templates/next-app/src/base/port/DBMigrationInterface.ts +92 -0
  38. package/dist/templates/next-app/src/base/port/DBTableInterface.ts +3 -0
  39. package/dist/templates/next-app/src/base/port/I18nServiceInterface.ts +3 -2
  40. package/dist/templates/next-app/src/base/port/MigrationApiInterface.ts +3 -0
  41. package/dist/templates/next-app/src/base/port/ServerApiResponseInterface.ts +6 -0
  42. package/dist/templates/next-app/src/base/port/UserServiceInterface.ts +3 -2
  43. package/dist/templates/next-app/src/base/services/I18nService.ts +9 -45
  44. package/dist/templates/next-app/src/base/services/UserService.ts +9 -8
  45. package/dist/templates/next-app/src/base/services/appApi/AppApiPlugin.ts +63 -0
  46. package/dist/templates/next-app/src/base/services/appApi/AppUserApi.ts +72 -0
  47. package/dist/templates/next-app/src/base/services/appApi/AppUserApiBootstrap.ts +48 -0
  48. package/dist/templates/next-app/src/base/services/appApi/AppUserType.ts +51 -0
  49. package/dist/templates/next-app/src/base/services/migrations/MigrationsApi.ts +43 -0
  50. package/dist/templates/next-app/src/core/bootstraps/BootstrapServer.ts +30 -5
  51. package/dist/templates/next-app/src/core/bootstraps/BootstrapsRegistry.ts +4 -3
  52. package/dist/templates/next-app/src/server/AppErrorApi.ts +10 -0
  53. package/dist/templates/next-app/src/server/AppSuccessApi.ts +7 -0
  54. package/dist/templates/next-app/src/server/PasswordEncrypt.ts +12 -0
  55. package/dist/templates/next-app/src/server/ServerAuth.ts +50 -0
  56. package/dist/templates/next-app/src/server/SupabaseBridge.ts +124 -0
  57. package/dist/templates/next-app/src/server/UserCredentialToken.ts +49 -0
  58. package/dist/templates/next-app/src/server/port/CrentialTokenInterface.ts +5 -0
  59. package/dist/templates/next-app/src/server/port/ServerInterface.ts +22 -0
  60. package/dist/templates/next-app/src/server/port/UserAuthInterface.ts +9 -0
  61. package/dist/templates/next-app/src/server/port/UserRepositoryInterface.ts +15 -0
  62. package/dist/templates/next-app/src/server/port/UserServiceInterface.ts +8 -0
  63. package/dist/templates/next-app/src/server/port/ValidatorInterface.ts +23 -0
  64. package/dist/templates/next-app/src/server/repositorys/UserRepository.ts +63 -0
  65. package/dist/templates/next-app/src/server/services/UserService.ts +105 -0
  66. package/dist/templates/next-app/src/server/validators/LoginValidator.ts +79 -0
  67. package/dist/templates/next-app/src/uikit/components/BaseHeader.tsx +1 -1
  68. package/dist/templates/next-app/src/uikit/components/BootstrapsProvider.tsx +8 -1
  69. package/dist/templates/next-app/src/uikit/components/LogoutButton.tsx +20 -10
  70. package/dist/templates/next-app/tsconfig.json +3 -1
  71. package/package.json +2 -2
  72. package/dist/templates/next-app/src/base/cases/ServerAuth.ts +0 -17
  73. package/dist/templates/next-app/src/base/port/ServerAuthInterface.ts +0 -3
  74. package/dist/templates/next-app/src/base/port/ServerInterface.ts +0 -12
  75. /package/dist/templates/next-app/src/{app/[locale]/login → uikit/components}/FeatureItem.tsx +0 -0
@@ -0,0 +1,12 @@
1
+ import crypto from 'crypto';
2
+ import type { Encryptor } from '@qlover/fe-corekit';
3
+
4
+ export class PasswordEncrypt implements Encryptor<string, string> {
5
+ encrypt(password: string): string {
6
+ return crypto.createHash('md5').update(password).digest('hex');
7
+ }
8
+
9
+ decrypt(): string {
10
+ throw new Error('Md5Encrypt is not decryptable');
11
+ }
12
+ }
@@ -0,0 +1,50 @@
1
+ import { inject, injectable } from 'inversify';
2
+ import { cookies } from 'next/headers';
3
+ import { I } from '@config/IOCIdentifier';
4
+ import type { AppConfig } from '@/base/cases/AppConfig';
5
+ import { UserCredentialToken } from './UserCredentialToken';
6
+ import type { UserAuthInterface } from './port/UserAuthInterface';
7
+
8
+ @injectable()
9
+ export class ServerAuth implements UserAuthInterface {
10
+ protected userTokenKey: string;
11
+ constructor(
12
+ @inject(I.AppConfig) protected server: AppConfig,
13
+ @inject(UserCredentialToken)
14
+ protected userCredentialToken: UserCredentialToken
15
+ ) {
16
+ this.userTokenKey = server.userTokenKey;
17
+ }
18
+
19
+ async setAuth(credential_token: string): Promise<void> {
20
+ const cookieStore = await cookies();
21
+
22
+ cookieStore.set(this.userTokenKey, credential_token);
23
+ }
24
+
25
+ async hasAuth(): Promise<boolean> {
26
+ const token = await this.getAuth();
27
+
28
+ if (!token) {
29
+ return false;
30
+ }
31
+
32
+ try {
33
+ const user = await this.userCredentialToken.parseToken(token);
34
+
35
+ return !!user;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ async getAuth(): Promise<string> {
42
+ const cookieStore = await cookies();
43
+ return cookieStore.get(this.userTokenKey)?.value || '';
44
+ }
45
+
46
+ async clear(): Promise<void> {
47
+ const cookieStore = await cookies();
48
+ cookieStore.delete(this.userTokenKey);
49
+ }
50
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ createClient,
3
+ type PostgrestSingleResponse,
4
+ type SupabaseClient
5
+ } from '@supabase/supabase-js';
6
+ import { injectable, inject } from 'inversify';
7
+ import { I } from '@config/IOCIdentifier';
8
+ import type { AppConfig } from '@/base/cases/AppConfig';
9
+ import type {
10
+ BridgeEvent,
11
+ DBBridgeInterface,
12
+ Where
13
+ } from '@/base/port/DBBridgeInterface';
14
+ import type { LoggerInterface } from '@qlover/logger';
15
+ import type { PostgrestFilterBuilder } from '@supabase/postgrest-js';
16
+
17
+ const whereHandlerMaps = {
18
+ '=': 'eq',
19
+ '!=': 'neq',
20
+ '>': 'gt',
21
+ '<': 'lt',
22
+ '>=': 'gte',
23
+ '<=': 'lte'
24
+ };
25
+
26
+ @injectable()
27
+ export class SupabaseBridge implements DBBridgeInterface {
28
+ protected supabase: SupabaseClient;
29
+
30
+ constructor(
31
+ @inject(I.AppConfig) appConfig: AppConfig,
32
+ @inject(I.Logger) protected logger: LoggerInterface
33
+ ) {
34
+ this.supabase = createClient(
35
+ appConfig.supabaseUrl,
36
+ appConfig.supabaseAnonKey
37
+ );
38
+ }
39
+
40
+ getSupabase(): SupabaseClient {
41
+ return this.supabase;
42
+ }
43
+
44
+ async execSql(sql: string): Promise<PostgrestSingleResponse<unknown>> {
45
+ const res = await this.supabase.rpc('exec_sql', { sql });
46
+ return this.catch(res);
47
+ }
48
+
49
+ protected async catch(
50
+ result: PostgrestSingleResponse<unknown>
51
+ ): Promise<PostgrestSingleResponse<unknown>> {
52
+ if (result.error) {
53
+ this.logger.info(result);
54
+ throw new Error(result.error.message);
55
+ }
56
+ return result;
57
+ }
58
+
59
+ protected handleWhere(
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ handler: PostgrestFilterBuilder<any, any, any, any, string, unknown, any>,
62
+ wheres: Where[]
63
+ ): void {
64
+ for (const where of wheres) {
65
+ const [key, operator, value] = where;
66
+ const opr = whereHandlerMaps[operator];
67
+ if (!opr) {
68
+ throw new Error(`Unsupported where operation: ${value}`);
69
+ }
70
+
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ if (typeof (handler as any)[opr] !== 'function') {
73
+ throw new Error(`Unsupported where operation: ${value}`);
74
+ }
75
+
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ (handler as any)[opr](key, value);
78
+ }
79
+ }
80
+
81
+ async add(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
82
+ const { table, data } = event;
83
+ if (!data) {
84
+ throw new Error('Data is required for add operation');
85
+ }
86
+ const res = await this.supabase
87
+ .from(table)
88
+ .insert(Array.isArray(data) ? data : [data])
89
+ .select();
90
+ return this.catch(res);
91
+ }
92
+
93
+ async update(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
94
+ const { table, data, where } = event;
95
+ if (!data) {
96
+ throw new Error('Data is required for update operation');
97
+ }
98
+
99
+ const handler = this.supabase.from(table).update(data);
100
+
101
+ this.handleWhere(handler, where ?? []);
102
+
103
+ return this.catch(await handler);
104
+ }
105
+
106
+ async delete(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
107
+ const { table, where } = event;
108
+ const handler = this.supabase.from(table).delete();
109
+
110
+ this.handleWhere(handler, where ?? []);
111
+
112
+ return this.catch(await handler);
113
+ }
114
+
115
+ async get(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
116
+ const { table, fields = '*', where } = event;
117
+ const selectFields = Array.isArray(fields) ? fields.join(',') : fields;
118
+ const handler = this.supabase.from(table).select(selectFields);
119
+
120
+ this.handleWhere(handler, where ?? []);
121
+
122
+ return this.catch(await handler);
123
+ }
124
+ }
@@ -0,0 +1,49 @@
1
+ import { inject, injectable } from 'inversify';
2
+ import jwt from 'jsonwebtoken';
3
+ import { AppConfig } from '@/base/cases/AppConfig';
4
+ import type { CrentialTokenInterface } from './port/CrentialTokenInterface';
5
+ import type { UserSchema } from '@migrations/schema/UserSchema';
6
+
7
+ export type UserCredentialTokenValue = Pick<UserSchema, 'id' | 'email'>;
8
+
9
+ @injectable()
10
+ export class UserCredentialToken
11
+ implements CrentialTokenInterface<UserCredentialTokenValue>
12
+ {
13
+ protected jwtSecret: string;
14
+ protected jwtExpiresIn: string;
15
+
16
+ constructor(@inject(AppConfig) protected config: AppConfig) {
17
+ this.jwtSecret = config.jwtSecret;
18
+ this.jwtExpiresIn = config.jwtExpiresIn;
19
+ }
20
+
21
+ async generateToken(
22
+ data: UserCredentialTokenValue,
23
+ options: { expiresIn?: string } = {}
24
+ ): Promise<string> {
25
+ const { expiresIn = '30 days' } = options;
26
+
27
+ return jwt.sign({ i: data.id, e: data.email }, this.jwtSecret, {
28
+ expiresIn: expiresIn as jwt.SignOptions['expiresIn'],
29
+ algorithm: 'HS256',
30
+ noTimestamp: true
31
+ });
32
+ }
33
+
34
+ async parseToken(token: string): Promise<UserCredentialTokenValue> {
35
+ try {
36
+ const decoded = jwt.verify(token, this.jwtSecret) as {
37
+ i: UserSchema['id'];
38
+ e: string;
39
+ };
40
+
41
+ return {
42
+ id: decoded.i,
43
+ email: decoded.e
44
+ };
45
+ } catch {
46
+ throw new Error('Invalid token');
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,5 @@
1
+ export interface CrentialTokenInterface<T> {
2
+ generateToken(data: T, options?: { expiresIn?: string }): Promise<string>;
3
+
4
+ parseToken(token: string): Promise<T>;
5
+ }
@@ -0,0 +1,22 @@
1
+ import { type ExecutorError, type PromiseTask } from '@qlover/fe-corekit';
2
+ import { type IOCIdentifierMapServer } from '@config/IOCIdentifier';
3
+ import type {
4
+ ServiceIdentifier,
5
+ IOCContainerInterface,
6
+ IOCFunctionInterface,
7
+ LoggerInterface
8
+ } from '@qlover/corekit-bridge';
9
+
10
+ export interface ServerInterface {
11
+ readonly logger: LoggerInterface;
12
+
13
+ getIOC(): IOCFunctionInterface<IOCIdentifierMapServer, IOCContainerInterface>;
14
+ getIOC<T extends keyof IOCIdentifierMapServer>(
15
+ identifier: T
16
+ ): IOCIdentifierMapServer[T];
17
+ getIOC<T>(serviceIdentifier: ServiceIdentifier<T>): T;
18
+
19
+ execNoError<Result>(
20
+ task?: PromiseTask<Result, unknown>
21
+ ): Promise<Result | ExecutorError>;
22
+ }
@@ -0,0 +1,9 @@
1
+ export interface UserAuthInterface {
2
+ setAuth(credential_token: string): Promise<void>;
3
+
4
+ getAuth(): Promise<string>;
5
+
6
+ clear(): Promise<void>;
7
+
8
+ hasAuth(): Promise<boolean>;
9
+ }
@@ -0,0 +1,15 @@
1
+ import type { DBTableInterface } from '@/base/port/DBTableInterface';
2
+ import type { UserSchema } from '@migrations/schema/UserSchema';
3
+
4
+ export interface UserRepositoryInterface extends DBTableInterface {
5
+ getUserByEmail(email: string): Promise<UserSchema | null>;
6
+ add(params: {
7
+ email: string;
8
+ password: string;
9
+ }): Promise<UserSchema[] | null>;
10
+
11
+ updateById(
12
+ id: number,
13
+ params: Partial<Omit<UserSchema, 'id' | 'created_at'>>
14
+ ): Promise<void>;
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { UserSchema } from '@migrations/schema/UserSchema';
2
+
3
+ export interface UserServiceInterface {
4
+ register(params: { email: string; password: string }): Promise<UserSchema>;
5
+ login(params: { email: string; password: string }): Promise<unknown>;
6
+
7
+ logout(): Promise<void>;
8
+ }
@@ -0,0 +1,23 @@
1
+ export interface ValidationFaildResult {
2
+ path: PropertyKey[];
3
+ message: string;
4
+ }
5
+
6
+ export interface ValidatorInterface {
7
+ /**
8
+ * Validate the data and return validation result
9
+ * @param data - The data to validate
10
+ * @returns true if validation passes, or ValidationError if validation fails
11
+ */
12
+ validate(
13
+ data: unknown
14
+ ): Promise<void | ValidationFaildResult> | void | ValidationFaildResult;
15
+
16
+ /**
17
+ * Get the data if it is valid, otherwise throw an error with validation details
18
+ * @param data - The data to validate
19
+ * @returns The data if it is valid
20
+ * @throws {import('@qlover/fe-corekit').ExecutorError} if the data is invalid, with validation errors
21
+ */
22
+ getThrow(data: unknown): unknown;
23
+ }
@@ -0,0 +1,63 @@
1
+ import { inject, injectable } from 'inversify';
2
+ import { isEmpty, last } from 'lodash';
3
+ import type { DBBridgeInterface } from '@/base/port/DBBridgeInterface';
4
+ import { SupabaseBridge } from '../SupabaseBridge';
5
+ import type { UserRepositoryInterface } from '../port/UserRepositoryInterface';
6
+ import type { UserSchema } from '@migrations/schema/UserSchema';
7
+
8
+ @injectable()
9
+ export class UserRepository implements UserRepositoryInterface {
10
+ readonly name = 'fe_users';
11
+
12
+ constructor(@inject(SupabaseBridge) protected dbBridge: DBBridgeInterface) {}
13
+
14
+ getAll(): Promise<unknown> {
15
+ return this.dbBridge.get({ table: this.name });
16
+ }
17
+
18
+ /**
19
+ * @override
20
+ */
21
+ async getUserByEmail(email: string): Promise<UserSchema | null> {
22
+ const result = await this.dbBridge.get({
23
+ table: this.name,
24
+ where: [['email', '=', email]]
25
+ });
26
+
27
+ if (isEmpty(result.data)) {
28
+ return null;
29
+ }
30
+
31
+ return last(result.data as UserSchema[]) ?? null;
32
+ }
33
+
34
+ /**
35
+ * @override
36
+ */
37
+ async add(params: {
38
+ email: string;
39
+ password: string;
40
+ }): Promise<UserSchema[] | null> {
41
+ const result = await this.dbBridge.add({
42
+ table: this.name,
43
+ data: params
44
+ });
45
+
46
+ if (isEmpty(result.data)) {
47
+ return null;
48
+ }
49
+
50
+ return result.data as UserSchema[];
51
+ }
52
+
53
+ async updateById(
54
+ id: number,
55
+ params: Partial<Omit<UserSchema, 'id' | 'created_at'>>
56
+ ): Promise<void> {
57
+ await this.dbBridge.update({
58
+ table: this.name,
59
+ data: params,
60
+ where: [['id', '=', id]]
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,105 @@
1
+ import { inject, injectable } from 'inversify';
2
+ import { isEmpty, last, omit } from 'lodash';
3
+ import {
4
+ API_USER_NOT_FOUND,
5
+ API_USER_ALREADY_EXISTS
6
+ } from '@config/Identifier/api';
7
+ import { PasswordEncrypt } from '../PasswordEncrypt';
8
+ import { UserRepository } from '../repositorys/UserRepository';
9
+ import { ServerAuth } from '../ServerAuth';
10
+ import {
11
+ UserCredentialToken,
12
+ type UserCredentialTokenValue
13
+ } from '../UserCredentialToken';
14
+ import type { CrentialTokenInterface } from '../port/CrentialTokenInterface';
15
+ import type { UserAuthInterface } from '../port/UserAuthInterface';
16
+ import type { UserRepositoryInterface } from '../port/UserRepositoryInterface';
17
+ import type { UserServiceInterface } from '../port/UserServiceInterface';
18
+ import type { UserSchema } from '@migrations/schema/UserSchema';
19
+ import type { Encryptor } from '@qlover/fe-corekit';
20
+
21
+ @injectable()
22
+ export class UserService implements UserServiceInterface {
23
+ constructor(
24
+ @inject(UserRepository)
25
+ protected userRepository: UserRepositoryInterface,
26
+ @inject(ServerAuth)
27
+ protected userAuth: UserAuthInterface,
28
+ @inject(PasswordEncrypt)
29
+ protected encryptor: Encryptor<string, string>,
30
+ @inject(UserCredentialToken)
31
+ protected credentialToken: CrentialTokenInterface<UserCredentialTokenValue>
32
+ ) {}
33
+
34
+ async register(params: {
35
+ email: string;
36
+ password: string;
37
+ }): Promise<UserSchema> {
38
+ const user = await this.userRepository.getUserByEmail(params.email);
39
+
40
+ if (!isEmpty(user)) {
41
+ throw new Error(API_USER_ALREADY_EXISTS);
42
+ }
43
+
44
+ const result = await this.userRepository.add({
45
+ email: params.email,
46
+ password: this.encryptor.encrypt(params.password)
47
+ });
48
+
49
+ const target = last(result);
50
+ if (!target) {
51
+ throw new Error(API_USER_NOT_FOUND);
52
+ }
53
+
54
+ return omit(target, 'password') as UserSchema;
55
+ }
56
+
57
+ async login(params: {
58
+ email: string;
59
+ password: string;
60
+ }): Promise<UserSchema> {
61
+ const user = await this.userRepository.getUserByEmail(params.email);
62
+
63
+ if (!user) {
64
+ throw new Error(API_USER_NOT_FOUND);
65
+ }
66
+
67
+ const encryptedPassword = this.encryptor.encrypt(params.password);
68
+
69
+ if (encryptedPassword !== user.password) {
70
+ throw new Error(API_USER_NOT_FOUND);
71
+ }
72
+
73
+ const credentialToken = await this.credentialToken.generateToken(user);
74
+
75
+ await this.userRepository.updateById(user.id, {
76
+ credential_token: credentialToken
77
+ });
78
+
79
+ return Object.assign(omit(user, 'password') as UserSchema, {
80
+ credential_token: credentialToken
81
+ });
82
+ }
83
+
84
+ async logout(): Promise<void> {
85
+ const auth = await this.userAuth.getAuth();
86
+
87
+ if (!auth) {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ const user = await this.credentialToken.parseToken(auth);
93
+
94
+ console.log('user', user);
95
+
96
+ await this.userRepository.updateById(user.id, {
97
+ credential_token: ''
98
+ });
99
+ } catch {
100
+ return;
101
+ }
102
+
103
+ await this.userAuth.clear();
104
+ }
105
+ }
@@ -0,0 +1,79 @@
1
+ import { ExecutorError } from '@qlover/fe-corekit';
2
+ import { z } from 'zod';
3
+ import {
4
+ V_LOGIN_PARAMS_REQUIRED,
5
+ V_EMAIL_INVALID,
6
+ V_PASSWORD_MIN_LENGTH,
7
+ V_PASSWORD_MAX_LENGTH,
8
+ V_PASSWORD_SPECIAL_CHARS
9
+ } from '@config/Identifier/validator';
10
+ import type {
11
+ ValidatorInterface,
12
+ ValidationFaildResult
13
+ } from '../port/ValidatorInterface';
14
+
15
+ export interface LoginValidatorData {
16
+ email: string;
17
+ password: string;
18
+ }
19
+
20
+ const emailSchema = z.email({ error: V_EMAIL_INVALID });
21
+
22
+ const passwordSchema = z
23
+ .string()
24
+ .min(6, { error: V_PASSWORD_MIN_LENGTH })
25
+ .max(50, { error: V_PASSWORD_MAX_LENGTH })
26
+ .regex(/^\S+$/, { error: V_PASSWORD_SPECIAL_CHARS });
27
+
28
+ interface ExtendedExecutorError extends ExecutorError {
29
+ issues?: ValidationFaildResult[];
30
+ }
31
+
32
+ export class LoginValidator implements ValidatorInterface {
33
+ validateEmail(data: unknown): void | ValidationFaildResult {
34
+ const emailResult = emailSchema.safeParse(data);
35
+ if (!emailResult.success) {
36
+ return emailResult.error.issues[0];
37
+ }
38
+ }
39
+
40
+ validatePassword(data: unknown): void | ValidationFaildResult {
41
+ const passwordResult = passwordSchema.safeParse(data);
42
+ if (!passwordResult.success) {
43
+ return passwordResult.error.issues[0];
44
+ }
45
+ }
46
+
47
+ validate(data: unknown): void | ValidationFaildResult {
48
+ if (typeof data !== 'object' || data === null) {
49
+ return {
50
+ path: ['form'],
51
+ message: V_LOGIN_PARAMS_REQUIRED
52
+ };
53
+ }
54
+
55
+ const { email, password } = data as Record<string, unknown>;
56
+
57
+ let validateResult = this.validateEmail(email);
58
+ if (validateResult != null) {
59
+ return validateResult;
60
+ }
61
+
62
+ validateResult = this.validatePassword(password);
63
+ if (validateResult != null) {
64
+ return validateResult;
65
+ }
66
+ }
67
+
68
+ getThrow(data: unknown): LoginValidatorData {
69
+ const result = this.validate(data);
70
+
71
+ if (result == null) {
72
+ return data as LoginValidatorData;
73
+ }
74
+
75
+ const error: ExtendedExecutorError = new ExecutorError(result.message);
76
+ error.issues = [result];
77
+ throw error;
78
+ }
79
+ }
@@ -31,7 +31,7 @@ export function BaseHeader(props: { showLogoutButton?: boolean }) {
31
31
  </span>
32
32
  </Link>
33
33
  </div>
34
- <div className="flex items-center gap-2">
34
+ <div className="flex items-center gap-2 md:gap-4">
35
35
  <LanguageSwitcher i18nService={i18nService} />
36
36
  <ThemeSwitcher />
37
37
  {showLogoutButton && <LogoutButton />}
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
  import '@ant-design/v5-patch-for-react-19';
3
3
  import { useRouter } from 'next/navigation';
4
- import { useLocale } from 'next-intl';
4
+ import { useLocale, useTranslations } from 'next-intl';
5
5
  import { useEffect } from 'react';
6
6
  import { I } from '@config/IOCIdentifier';
7
7
  import { NavigateBridge } from '@/base/cases/NavigateBridge';
8
+ import type { I18nServiceLocale } from '@/base/port/I18nServiceInterface';
8
9
  import { BootstrapClient } from '@/core/bootstraps/BootstrapClient';
9
10
  import { clientIOC } from '@/core/clientIoc/ClientIOC';
10
11
  import { IOCContext } from '../context/IOCContext';
@@ -13,12 +14,18 @@ export function BootstrapsProvider(props: { children: React.ReactNode }) {
13
14
  const IOC = clientIOC.create();
14
15
  const locale = useLocale();
15
16
  const router = useRouter();
17
+ const t = useTranslations();
16
18
 
17
19
  useEffect(() => {
18
20
  IOC(I.RouterServiceInterface).setLocale(locale);
19
21
  IOC(NavigateBridge).setUIBridge(router);
20
22
  }, [locale, router, IOC]);
21
23
 
24
+ useEffect(() => {
25
+ IOC(I.I18nServiceInterface).changeLanguage(locale as I18nServiceLocale);
26
+ IOC(I.I18nServiceInterface).setTranslator(t);
27
+ }, [t, IOC, locale]);
28
+
22
29
  useEffect(() => {
23
30
  if (typeof window !== 'undefined') {
24
31
  BootstrapClient.main({
@@ -1,34 +1,44 @@
1
- import { Button } from 'antd';
1
+ import { LogoutOutlined } from '@ant-design/icons';
2
+ import { Tooltip } from 'antd';
2
3
  import { useCallback } from 'react';
3
4
  import {
4
5
  AUTH_LOGOUT_DIALOG_CONTENT,
5
6
  AUTH_LOGOUT_DIALOG_TITLE
6
7
  } from '@config/Identifier';
7
- import { IOCIdentifier } from '@config/IOCIdentifier';
8
+ import { I } from '@config/IOCIdentifier';
8
9
  import { useI18nInterface } from '../hook/useI18nInterface';
9
10
  import { useIOC } from '../hook/useIOC';
10
11
  import type { PageI18nInterface } from '@config/i18n';
11
12
 
12
13
  export function LogoutButton() {
13
- const IOC = useIOC();
14
+ const dialogHandler = useIOC(I.DialogHandler);
15
+ const userService = useIOC(I.UserServiceInterface);
16
+ const routerService = useIOC(I.RouterServiceInterface);
17
+
14
18
  const tt = useI18nInterface({
15
19
  title: AUTH_LOGOUT_DIALOG_TITLE,
16
20
  content: AUTH_LOGOUT_DIALOG_CONTENT
17
21
  } as PageI18nInterface);
18
22
 
19
23
  const onClick = useCallback(() => {
20
- IOC(IOCIdentifier.DialogHandler).confirm({
24
+ dialogHandler.confirm({
21
25
  title: tt.title,
22
26
  content: tt.content,
23
- onOk: () => {
24
- IOC(IOCIdentifier.UserServiceInterface).logout();
27
+ onOk: async () => {
28
+ await userService.logout();
29
+ routerService.gotoLogin();
25
30
  }
26
31
  });
27
- }, [tt, IOC]);
32
+ }, [tt, dialogHandler, userService, routerService]);
28
33
 
29
34
  return (
30
- <Button data-testid="LogoutButton" danger onClick={onClick}>
31
- {tt.title}
32
- </Button>
35
+ <Tooltip data-testid="LogoutIcon" title={tt.title} placement="right">
36
+ <span
37
+ className="text-text hover:text-red-500 cursor-pointer text-lg transition-colors"
38
+ onClick={onClick}
39
+ >
40
+ <LogoutOutlined />
41
+ </span>
42
+ </Tooltip>
33
43
  );
34
44
  }