@idevconn/create-icore 0.6.3 → 0.7.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 (87) hide show
  1. package/dist/cli.js +384 -276
  2. package/dist/index.cjs +385 -277
  3. package/dist/index.d.cts +3 -3
  4. package/dist/index.d.ts +3 -3
  5. package/dist/index.js +382 -274
  6. package/package.json +1 -1
  7. package/templates/.yarn/releases/yarn-4.16.0.cjs +944 -0
  8. package/templates/.yarnrc.yml +1 -1
  9. package/templates/apps/api/src/app/storage/storage.controller.ts +28 -0
  10. package/templates/apps/microservices/auth/src/app/app.module.ts +20 -2
  11. package/templates/apps/microservices/notes/src/app/app.module.ts +17 -2
  12. package/templates/apps/microservices/upload/src/app/app.module.ts +17 -2
  13. package/templates/apps/microservices/upload/src/app/storage.controller.ts +7 -0
  14. package/templates/apps/templates/client-antd/src/components/auth/AuthBrandPanel.tsx +59 -0
  15. package/templates/apps/templates/client-antd/src/components/auth/CheckEmailScreen.tsx +28 -0
  16. package/templates/apps/templates/client-antd/src/components/auth/LoginForm.tsx +116 -0
  17. package/templates/apps/templates/client-antd/src/components/auth/MagicLinkForm.tsx +95 -0
  18. package/templates/apps/templates/client-antd/src/components/auth/RegisterForm.tsx +98 -0
  19. package/templates/apps/templates/client-antd/src/globals.less +6 -0
  20. package/templates/apps/templates/client-antd/src/main.tsx +1 -1
  21. package/templates/apps/templates/client-antd/src/routes/login.tsx +45 -181
  22. package/templates/apps/templates/client-mui/src/components/auth/AuthBrandPanel.tsx +59 -0
  23. package/templates/apps/templates/client-mui/src/components/auth/CheckEmailScreen.tsx +28 -0
  24. package/templates/apps/templates/client-mui/src/components/auth/LoginForm.tsx +141 -0
  25. package/templates/apps/templates/client-mui/src/components/auth/MagicLinkForm.tsx +106 -0
  26. package/templates/apps/templates/client-mui/src/components/auth/RegisterForm.tsx +113 -0
  27. package/templates/apps/templates/client-mui/src/main.tsx +1 -1
  28. package/templates/apps/templates/client-mui/src/routes/login.tsx +50 -186
  29. package/templates/apps/templates/client-shadcn/src/components/auth/AuthBrandPanel.tsx +52 -0
  30. package/templates/apps/templates/client-shadcn/src/components/auth/CheckEmailScreen.tsx +29 -0
  31. package/templates/apps/templates/client-shadcn/src/components/auth/LoginForm.tsx +161 -0
  32. package/templates/apps/templates/client-shadcn/src/components/auth/MagicLinkForm.tsx +110 -0
  33. package/templates/apps/templates/client-shadcn/src/components/auth/RegisterForm.tsx +107 -0
  34. package/templates/apps/templates/client-shadcn/src/components/layout/LayoutHeader.tsx +31 -10
  35. package/templates/apps/templates/client-shadcn/src/components/layout/LayoutSider.tsx +22 -27
  36. package/templates/apps/templates/client-shadcn/src/components/ui/card.tsx +1 -1
  37. package/templates/apps/templates/client-shadcn/src/globals.css +39 -13
  38. package/templates/apps/templates/client-shadcn/src/routes/auth.callback.tsx +1 -1
  39. package/templates/apps/templates/client-shadcn/src/routes/login.tsx +55 -165
  40. package/templates/libs/auth-strategies/mongodb/CHANGELOG.md +8 -0
  41. package/templates/libs/auth-strategies/mongodb/README.md +11 -0
  42. package/templates/libs/auth-strategies/mongodb/eslint.config.mjs +19 -0
  43. package/templates/libs/auth-strategies/mongodb/jest.config.cts +10 -0
  44. package/templates/libs/auth-strategies/mongodb/package.json +16 -0
  45. package/templates/libs/auth-strategies/mongodb/project.json +19 -0
  46. package/templates/libs/auth-strategies/mongodb/src/index.ts +1 -0
  47. package/templates/libs/auth-strategies/mongodb/src/lib/__tests__/mongodb-auth.strategy.unit.test.ts +42 -0
  48. package/templates/libs/auth-strategies/mongodb/src/lib/auth-mongodb.spec.ts +7 -0
  49. package/templates/libs/auth-strategies/mongodb/src/lib/auth-mongodb.ts +3 -0
  50. package/templates/libs/auth-strategies/mongodb/src/lib/mongodb-auth.strategy.ts +188 -0
  51. package/templates/libs/auth-strategies/mongodb/tsconfig.json +23 -0
  52. package/templates/libs/auth-strategies/mongodb/tsconfig.lib.json +10 -0
  53. package/templates/libs/auth-strategies/mongodb/tsconfig.spec.json +16 -0
  54. package/templates/libs/db-strategies/mongodb/CHANGELOG.md +7 -0
  55. package/templates/libs/db-strategies/mongodb/README.md +11 -0
  56. package/templates/libs/db-strategies/mongodb/eslint.config.mjs +19 -0
  57. package/templates/libs/db-strategies/mongodb/jest.config.cts +10 -0
  58. package/templates/libs/db-strategies/mongodb/package.json +14 -0
  59. package/templates/libs/db-strategies/mongodb/project.json +19 -0
  60. package/templates/libs/db-strategies/mongodb/src/index.ts +1 -0
  61. package/templates/libs/db-strategies/mongodb/src/lib/__tests__/mongodb-db.strategy.unit.test.ts +38 -0
  62. package/templates/libs/db-strategies/mongodb/src/lib/mongodb-db.strategy.ts +108 -0
  63. package/templates/libs/db-strategies/mongodb/src/lib/mongodb.spec.ts +7 -0
  64. package/templates/libs/db-strategies/mongodb/src/lib/mongodb.ts +3 -0
  65. package/templates/libs/db-strategies/mongodb/tsconfig.json +23 -0
  66. package/templates/libs/db-strategies/mongodb/tsconfig.lib.json +10 -0
  67. package/templates/libs/db-strategies/mongodb/tsconfig.spec.json +16 -0
  68. package/templates/libs/shared/src/strategies/storage.ts +3 -0
  69. package/templates/libs/storage-strategies/mongodb/CHANGELOG.md +8 -0
  70. package/templates/libs/storage-strategies/mongodb/README.md +11 -0
  71. package/templates/libs/storage-strategies/mongodb/eslint.config.mjs +19 -0
  72. package/templates/libs/storage-strategies/mongodb/jest.config.cts +10 -0
  73. package/templates/libs/storage-strategies/mongodb/package.json +14 -0
  74. package/templates/libs/storage-strategies/mongodb/project.json +19 -0
  75. package/templates/libs/storage-strategies/mongodb/src/index.ts +1 -0
  76. package/templates/libs/storage-strategies/mongodb/src/lib/__tests__/mongodb-storage.strategy.unit.test.ts +38 -0
  77. package/templates/libs/storage-strategies/mongodb/src/lib/mongodb-storage.strategy.ts +93 -0
  78. package/templates/libs/storage-strategies/mongodb/src/lib/storage-mongodb.spec.ts +7 -0
  79. package/templates/libs/storage-strategies/mongodb/src/lib/storage-mongodb.ts +3 -0
  80. package/templates/libs/storage-strategies/mongodb/tsconfig.json +23 -0
  81. package/templates/libs/storage-strategies/mongodb/tsconfig.lib.json +10 -0
  82. package/templates/libs/storage-strategies/mongodb/tsconfig.spec.json +16 -0
  83. package/templates/libs/template-shared/src/lib/i18n/keys.ts +216 -56
  84. package/templates/libs/template-shared/src/lib/stores/theme.store.ts +1 -6
  85. package/templates/libs/upload-client/src/lib/upload-client.service.ts +7 -0
  86. package/templates/tsconfig.base.json +4 -1
  87. package/templates/.yarn/releases/yarn-4.15.0.cjs +0 -940
@@ -38,7 +38,7 @@ function Root() {
38
38
  const mode = useThemeStore((s) => s.mode);
39
39
  const algorithm = mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm;
40
40
  return (
41
- <ConfigProvider theme={{ algorithm }}>
41
+ <ConfigProvider theme={{ algorithm, token: { colorPrimary: '#22c55e', colorLink: '#22c55e' } }}>
42
42
  <AntApp>
43
43
  <QueryClientProvider client={queryClient}>
44
44
  <AbilityProvider>
@@ -1,193 +1,57 @@
1
- import { createFileRoute, useNavigate } from '@tanstack/react-router';
2
- import { Button, Card, Form, Input, Result, Segmented, Space } from 'antd';
3
- import { GithubOutlined, GoogleOutlined } from '@ant-design/icons';
4
1
  import { useState } from 'react';
5
- import { useTranslation } from 'react-i18next';
6
- import { useAuthStore, useNotify } from '@icore/template-shared';
7
- import { api } from '../main';
2
+ import { createFileRoute } from '@tanstack/react-router';
3
+ import { AuthBrandPanel } from '../components/auth/AuthBrandPanel';
4
+ import { CheckEmailScreen } from '../components/auth/CheckEmailScreen';
5
+ import { LoginForm } from '../components/auth/LoginForm';
6
+ import { MagicLinkForm } from '../components/auth/MagicLinkForm';
7
+ import { RegisterForm } from '../components/auth/RegisterForm';
8
8
 
9
- type Mode = 'password' | 'magicLinkRequest' | 'magicLinkSent';
10
-
11
- interface PasswordFormValues {
12
- email: string;
13
- password: string;
14
- }
15
-
16
- interface MagicLinkFormValues {
17
- email: string;
18
- }
9
+ type Mode = 'login' | 'register' | 'magicLink' | 'checkEmail';
19
10
 
20
11
  function LoginPage() {
21
- const { t } = useTranslation();
22
- const navigate = useNavigate();
23
- const notify = useNotify();
24
- const setAuth = useAuthStore((s) => s.setAuth);
12
+ const [mode, setMode] = useState<Mode>('login');
13
+ const [checkEmail, setCheckEmail] = useState('');
25
14
 
26
- const [mode, setMode] = useState<Mode>('password');
27
- const [sentEmail, setSentEmail] = useState('');
28
- const [passwordForm] = Form.useForm<PasswordFormValues>();
29
- const [magicLinkForm] = Form.useForm<MagicLinkFormValues>();
30
-
31
- async function handlePasswordFinish(values: PasswordFormValues) {
32
- try {
33
- const session = await api<{
34
- accessToken: string;
35
- refreshToken: string;
36
- user: { id: string; email: string; role?: string };
37
- }>('/auth/login', {
38
- method: 'POST',
39
- headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify({ email: values.email, password: values.password }),
41
- });
42
- setAuth(session);
43
- notify.success(t('auth.login'));
44
- await navigate({ to: '/dashboard' });
45
- } catch (err) {
46
- notify.error(err instanceof Error ? err.message : t('error.unknown'));
47
- }
48
- }
49
-
50
- async function handleMagicLinkFinish(values: MagicLinkFormValues) {
51
- try {
52
- await api('/auth/magic-link', {
53
- method: 'POST',
54
- headers: { 'Content-Type': 'application/json' },
55
- body: JSON.stringify({ email: values.email }),
56
- });
57
- setSentEmail(values.email);
58
- setMode('magicLinkSent');
59
- } catch (err) {
60
- notify.error(err instanceof Error ? err.message : t('error.unknown'));
61
- }
15
+ function handleRegisterSuccess(email: string) {
16
+ setCheckEmail(email);
17
+ setMode('checkEmail');
62
18
  }
63
19
 
64
20
  return (
65
- <main
66
- style={{
67
- minHeight: '100vh',
68
- display: 'flex',
69
- alignItems: 'center',
70
- justifyContent: 'center',
71
- padding: 24,
72
- }}
73
- >
74
- <Card style={{ width: '100%', maxWidth: 380 }}>
75
- <h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 4 }}>{t('auth.login')}</h1>
76
- <p style={{ marginBottom: 16, color: 'rgba(0,0,0,0.45)' }}>
77
- {t('auth.email')} &amp; {t('auth.password')}
78
- </p>
79
-
80
- {mode !== 'magicLinkSent' && (
81
- <>
82
- <Segmented
83
- block
84
- value={mode}
85
- onChange={(v) => setMode(v as Mode)}
86
- options={[
87
- { label: t('auth.withPassword'), value: 'password' },
88
- { label: t('auth.withMagicLink'), value: 'magicLinkRequest' },
89
- ]}
90
- style={{ marginBottom: 16 }}
21
+ <div style={{ minHeight: '100vh', display: 'flex', background: '#020617' }}>
22
+ <div style={{ flex: 1, display: 'none' }} className="auth-brand-lg">
23
+ <AuthBrandPanel />
24
+ </div>
25
+
26
+ <div
27
+ style={{
28
+ flex: 1,
29
+ display: 'flex',
30
+ alignItems: 'center',
31
+ justifyContent: 'center',
32
+ padding: '40px 24px',
33
+ }}
34
+ >
35
+ <div style={{ width: '100%', maxWidth: 400 }}>
36
+ {mode === 'login' && (
37
+ <LoginForm
38
+ onSwitchRegister={() => setMode('register')}
39
+ onSwitchMagicLink={() => setMode('magicLink')}
91
40
  />
92
- <Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
93
- <Button
94
- block
95
- icon={<GoogleOutlined />}
96
- onClick={() => window.location.assign('/api/auth/oauth/google')}
97
- >
98
- {t('auth.continueWithGoogle')}
99
- </Button>
100
- <Button
101
- block
102
- icon={<GithubOutlined />}
103
- onClick={() => window.location.assign('/api/auth/oauth/github')}
104
- >
105
- {t('auth.continueWithGithub')}
106
- </Button>
107
- </Space>
108
- </>
109
- )}
110
-
111
- {mode === 'password' && (
112
- <Form
113
- form={passwordForm}
114
- layout="vertical"
115
- onFinish={handlePasswordFinish}
116
- autoComplete="on"
117
- >
118
- <Form.Item
119
- name="email"
120
- label={t('auth.email')}
121
- rules={[
122
- { required: true, message: `${t('auth.email')} is required` },
123
- { type: 'email', message: 'Please enter a valid email address' },
124
- ]}
125
- >
126
- <Input autoComplete="email" size="large" />
127
- </Form.Item>
128
-
129
- <Form.Item
130
- name="password"
131
- label={t('auth.password')}
132
- rules={[{ required: true, message: `${t('auth.password')} is required` }]}
133
- >
134
- <Input.Password autoComplete="current-password" size="large" />
135
- </Form.Item>
136
-
137
- <Form.Item style={{ marginBottom: 0 }}>
138
- <Button type="primary" htmlType="submit" block size="large">
139
- {t('auth.login')}
140
- </Button>
141
- </Form.Item>
142
- </Form>
143
- )}
144
-
145
- {mode === 'magicLinkRequest' && (
146
- <Form
147
- form={magicLinkForm}
148
- layout="vertical"
149
- onFinish={handleMagicLinkFinish}
150
- autoComplete="on"
151
- >
152
- <Form.Item
153
- name="email"
154
- label={t('auth.email')}
155
- rules={[
156
- { required: true, message: `${t('auth.email')} is required` },
157
- { type: 'email', message: 'Please enter a valid email address' },
158
- ]}
159
- >
160
- <Input autoComplete="email" size="large" />
161
- </Form.Item>
162
-
163
- <Form.Item style={{ marginBottom: 0 }}>
164
- <Button type="primary" htmlType="submit" block size="large">
165
- {t('auth.sendMagicLink')}
166
- </Button>
167
- </Form.Item>
168
- </Form>
169
- )}
170
-
171
- {mode === 'magicLinkSent' && (
172
- <Result
173
- status="success"
174
- title={t('auth.magicLinkSent')}
175
- subTitle={t('auth.magicLinkSentDescription', { email: sentEmail })}
176
- extra={
177
- <Button
178
- onClick={() => {
179
- setSentEmail('');
180
- magicLinkForm.resetFields();
181
- setMode('magicLinkRequest');
182
- }}
183
- >
184
- {t('auth.magicLinkUseDifferentEmail')}
185
- </Button>
186
- }
187
- />
188
- )}
189
- </Card>
190
- </main>
41
+ )}
42
+ {mode === 'register' && (
43
+ <RegisterForm
44
+ onSuccess={handleRegisterSuccess}
45
+ onSwitchLogin={() => setMode('login')}
46
+ />
47
+ )}
48
+ {mode === 'magicLink' && <MagicLinkForm onSwitchLogin={() => setMode('login')} />}
49
+ {mode === 'checkEmail' && (
50
+ <CheckEmailScreen email={checkEmail} onBack={() => setMode('login')} />
51
+ )}
52
+ </div>
53
+ </div>
54
+ </div>
191
55
  );
192
56
  }
193
57
 
@@ -0,0 +1,59 @@
1
+ import { Box, Stack, Typography } from '@mui/material';
2
+ import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
3
+
4
+ const FEATURES = [
5
+ 'Strategy-pattern auth & storage',
6
+ 'Multi-provider: password, magic link, OAuth',
7
+ 'CASL role-based access control',
8
+ ];
9
+
10
+ export function AuthBrandPanel() {
11
+ return (
12
+ <Box
13
+ sx={{
14
+ flex: 1,
15
+ background: 'linear-gradient(135deg, #0d1117 0%, #0f1e0f 100%)',
16
+ display: 'flex',
17
+ flexDirection: 'column',
18
+ justifyContent: 'center',
19
+ p: '48px 56px',
20
+ position: 'relative',
21
+ overflow: 'hidden',
22
+ }}
23
+ >
24
+ <Box
25
+ sx={{
26
+ position: 'absolute',
27
+ top: '20%',
28
+ left: '10%',
29
+ width: 320,
30
+ height: 320,
31
+ borderRadius: '50%',
32
+ background: 'radial-gradient(circle, rgba(34,197,94,0.15) 0%, transparent 70%)',
33
+ pointerEvents: 'none',
34
+ }}
35
+ />
36
+ <Stack spacing={4} sx={{ position: 'relative', zIndex: 1 }}>
37
+ <Stack spacing={0.5}>
38
+ <Typography variant="h4" fontWeight={700} sx={{ color: '#22c55e' }}>
39
+ iCore
40
+ </Typography>
41
+ <Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>
42
+ Enterprise scaffold for NestJS + React
43
+ </Typography>
44
+ </Stack>
45
+
46
+ <Stack spacing={1}>
47
+ {FEATURES.map((f) => (
48
+ <Stack key={f} direction="row" spacing={1} alignItems="center">
49
+ <CheckCircleOutlineIcon sx={{ color: '#22c55e', fontSize: 16 }} />
50
+ <Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.75)' }}>
51
+ {f}
52
+ </Typography>
53
+ </Stack>
54
+ ))}
55
+ </Stack>
56
+ </Stack>
57
+ </Box>
58
+ );
59
+ }
@@ -0,0 +1,28 @@
1
+ import { Button, Stack, Typography } from '@mui/material';
2
+ import MarkEmailReadOutlinedIcon from '@mui/icons-material/MarkEmailReadOutlined';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ interface Props {
6
+ email: string;
7
+ onBack: () => void;
8
+ }
9
+
10
+ export function CheckEmailScreen({ email, onBack }: Props) {
11
+ const { t } = useTranslation();
12
+ return (
13
+ <Stack spacing={3} alignItems="center" textAlign="center">
14
+ <MarkEmailReadOutlinedIcon sx={{ fontSize: 56, color: 'primary.main' }} />
15
+ <Stack spacing={1}>
16
+ <Typography variant="h5" fontWeight={600}>
17
+ {t('auth.checkEmail')}
18
+ </Typography>
19
+ <Typography variant="body2" color="text.secondary">
20
+ {t('auth.checkEmailDescription', { email })}
21
+ </Typography>
22
+ </Stack>
23
+ <Button variant="outlined" fullWidth onClick={onBack}>
24
+ {t('auth.backToLogin')}
25
+ </Button>
26
+ </Stack>
27
+ );
28
+ }
@@ -0,0 +1,141 @@
1
+ import { useState } from 'react';
2
+ import { Box, Button, Divider, Stack, TextField, Typography } from '@mui/material';
3
+ import GoogleIcon from '@mui/icons-material/Google';
4
+ import GitHubIcon from '@mui/icons-material/GitHub';
5
+ import { SyntheticEvent } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { useNavigate } from '@tanstack/react-router';
8
+ import { useAuthStore, useNotify } from '@icore/template-shared';
9
+ import { api } from '@/main';
10
+
11
+ interface Props {
12
+ onSwitchRegister: () => void;
13
+ onSwitchMagicLink: () => void;
14
+ }
15
+
16
+ export function LoginForm({ onSwitchRegister, onSwitchMagicLink }: Props) {
17
+ const { t } = useTranslation();
18
+ const navigate = useNavigate();
19
+ const notify = useNotify();
20
+ const setAuth = useAuthStore((s) => s.setAuth);
21
+
22
+ const [email, setEmail] = useState('');
23
+ const [password, setPassword] = useState('');
24
+ const [submitting, setSubmitting] = useState(false);
25
+
26
+ async function handleSubmit(e: SyntheticEvent<HTMLFormElement>) {
27
+ e.preventDefault();
28
+ setSubmitting(true);
29
+ try {
30
+ const session = await api<{
31
+ accessToken: string;
32
+ refreshToken: string;
33
+ user: { id: string; email: string; role?: string };
34
+ }>('/auth/login', {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ email, password }),
38
+ });
39
+ setAuth(session);
40
+ notify.success(t('auth.login'));
41
+ await navigate({ to: '/dashboard' });
42
+ } catch (err) {
43
+ notify.error(err instanceof Error ? err.message : t('error.unknown'));
44
+ } finally {
45
+ setSubmitting(false);
46
+ }
47
+ }
48
+
49
+ return (
50
+ <Stack spacing={2}>
51
+ <Stack spacing={0.5}>
52
+ <Typography variant="h5" fontWeight={600}>
53
+ {t('auth.loginTitle')}
54
+ </Typography>
55
+ <Typography variant="body2" color="text.secondary">
56
+ {t('auth.loginSubtitle')}
57
+ </Typography>
58
+ </Stack>
59
+
60
+ <Stack spacing={1}>
61
+ <Button
62
+ variant="outlined"
63
+ fullWidth
64
+ startIcon={<GoogleIcon />}
65
+ onClick={() => window.location.assign('/api/auth/oauth/google')}
66
+ >
67
+ {t('auth.continueWithGoogle')}
68
+ </Button>
69
+ <Button
70
+ variant="outlined"
71
+ fullWidth
72
+ startIcon={<GitHubIcon />}
73
+ onClick={() => window.location.assign('/api/auth/oauth/github')}
74
+ >
75
+ {t('auth.continueWithGithub')}
76
+ </Button>
77
+ </Stack>
78
+
79
+ <Divider>
80
+ <Typography variant="caption" color="text.secondary">
81
+ {t('auth.orContinueWith')}
82
+ </Typography>
83
+ </Divider>
84
+
85
+ <Box component="form" onSubmit={handleSubmit} autoComplete="on">
86
+ <TextField
87
+ label={t('auth.email')}
88
+ type="email"
89
+ autoComplete="email"
90
+ required
91
+ fullWidth
92
+ margin="normal"
93
+ value={email}
94
+ onChange={(e) => setEmail(e.target.value)}
95
+ />
96
+ <TextField
97
+ label={t('auth.password')}
98
+ type="password"
99
+ autoComplete="current-password"
100
+ required
101
+ fullWidth
102
+ margin="normal"
103
+ value={password}
104
+ onChange={(e) => setPassword(e.target.value)}
105
+ />
106
+ <Button type="submit" variant="contained" fullWidth disabled={submitting} sx={{ mt: 2 }}>
107
+ {t('auth.login')}
108
+ </Button>
109
+ </Box>
110
+
111
+ <Stack spacing={0.5} alignItems="center">
112
+ <Typography variant="body2" color="text.secondary">
113
+ {t('auth.switchToRegister')}{' '}
114
+ <Box
115
+ component="span"
116
+ onClick={onSwitchRegister}
117
+ sx={{
118
+ color: 'primary.main',
119
+ cursor: 'pointer',
120
+ '&:hover': { textDecoration: 'underline' },
121
+ }}
122
+ >
123
+ {t('auth.switchToRegisterLink')}
124
+ </Box>
125
+ </Typography>
126
+ <Box
127
+ component="span"
128
+ onClick={onSwitchMagicLink}
129
+ sx={{
130
+ fontSize: 13,
131
+ color: 'primary.main',
132
+ cursor: 'pointer',
133
+ '&:hover': { textDecoration: 'underline' },
134
+ }}
135
+ >
136
+ {t('auth.withMagicLink')}
137
+ </Box>
138
+ </Stack>
139
+ </Stack>
140
+ );
141
+ }
@@ -0,0 +1,106 @@
1
+ import { useState } from 'react';
2
+ import { Box, Button, Stack, TextField, Typography } from '@mui/material';
3
+ import MailOutlineIcon from '@mui/icons-material/MailOutline';
4
+ import { SyntheticEvent } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useNotify } from '@icore/template-shared';
7
+ import { api } from '@/main';
8
+
9
+ interface Props {
10
+ onSwitchLogin: () => void;
11
+ }
12
+
13
+ export function MagicLinkForm({ onSwitchLogin }: Props) {
14
+ const { t } = useTranslation();
15
+ const notify = useNotify();
16
+
17
+ const [email, setEmail] = useState('');
18
+ const [sentEmail, setSentEmail] = useState('');
19
+ const [submitting, setSubmitting] = useState(false);
20
+
21
+ async function handleSubmit(e: SyntheticEvent<HTMLFormElement>) {
22
+ e.preventDefault();
23
+ setSubmitting(true);
24
+ try {
25
+ await api('/auth/magic-link', {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ email }),
29
+ });
30
+ setSentEmail(email);
31
+ } catch (err) {
32
+ notify.error(err instanceof Error ? err.message : t('error.unknown'));
33
+ } finally {
34
+ setSubmitting(false);
35
+ }
36
+ }
37
+
38
+ if (sentEmail) {
39
+ return (
40
+ <Stack spacing={3} alignItems="center" textAlign="center">
41
+ <MailOutlineIcon sx={{ fontSize: 56, color: 'primary.main' }} />
42
+ <Stack spacing={1}>
43
+ <Typography variant="h5" fontWeight={600}>
44
+ {t('auth.magicLinkSent')}
45
+ </Typography>
46
+ <Typography variant="body2" color="text.secondary">
47
+ {t('auth.magicLinkSentDescription', { email: sentEmail })}
48
+ </Typography>
49
+ </Stack>
50
+ <Button
51
+ variant="outlined"
52
+ fullWidth
53
+ onClick={() => {
54
+ setEmail('');
55
+ setSentEmail('');
56
+ }}
57
+ >
58
+ {t('auth.magicLinkUseDifferentEmail')}
59
+ </Button>
60
+ </Stack>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <Stack spacing={2}>
66
+ <Stack spacing={0.5}>
67
+ <Typography variant="h5" fontWeight={600}>
68
+ {t('auth.withMagicLink')}
69
+ </Typography>
70
+ <Typography variant="body2" color="text.secondary">
71
+ {t('auth.loginSubtitle')}
72
+ </Typography>
73
+ </Stack>
74
+
75
+ <Box component="form" onSubmit={handleSubmit} autoComplete="on">
76
+ <TextField
77
+ label={t('auth.email')}
78
+ type="email"
79
+ autoComplete="email"
80
+ required
81
+ fullWidth
82
+ margin="normal"
83
+ value={email}
84
+ onChange={(e) => setEmail(e.target.value)}
85
+ />
86
+ <Button type="submit" variant="contained" fullWidth disabled={submitting} sx={{ mt: 2 }}>
87
+ {t('auth.sendMagicLink')}
88
+ </Button>
89
+ </Box>
90
+
91
+ <Typography variant="body2" color="text.secondary" textAlign="center">
92
+ <Box
93
+ component="span"
94
+ onClick={onSwitchLogin}
95
+ sx={{
96
+ color: 'primary.main',
97
+ cursor: 'pointer',
98
+ '&:hover': { textDecoration: 'underline' },
99
+ }}
100
+ >
101
+ {t('auth.backToLogin')}
102
+ </Box>
103
+ </Typography>
104
+ </Stack>
105
+ );
106
+ }