@idevconn/create-icore 0.6.2 → 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
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react';
2
+ import { Box, Button, Stack, TextField, Typography } from '@mui/material';
3
+ import { SyntheticEvent } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { useNotify } from '@icore/template-shared';
6
+ import { api } from '@/main';
7
+
8
+ interface Props {
9
+ onSuccess: (email: string) => void;
10
+ onSwitchLogin: () => void;
11
+ }
12
+
13
+ export function RegisterForm({ onSuccess, onSwitchLogin }: Props) {
14
+ const { t } = useTranslation();
15
+ const notify = useNotify();
16
+
17
+ const [email, setEmail] = useState('');
18
+ const [password, setPassword] = useState('');
19
+ const [confirmPassword, setConfirmPassword] = useState('');
20
+ const [mismatch, setMismatch] = useState(false);
21
+ const [submitting, setSubmitting] = useState(false);
22
+
23
+ async function handleSubmit(e: SyntheticEvent<HTMLFormElement>) {
24
+ e.preventDefault();
25
+ if (password !== confirmPassword) {
26
+ setMismatch(true);
27
+ return;
28
+ }
29
+ setMismatch(false);
30
+ setSubmitting(true);
31
+ try {
32
+ await api('/auth/register', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ email, password }),
36
+ });
37
+ onSuccess(email);
38
+ } catch (err) {
39
+ notify.error(err instanceof Error ? err.message : t('error.unknown'));
40
+ } finally {
41
+ setSubmitting(false);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <Stack spacing={2}>
47
+ <Stack spacing={0.5}>
48
+ <Typography variant="h5" fontWeight={600}>
49
+ {t('auth.registerTitle')}
50
+ </Typography>
51
+ <Typography variant="body2" color="text.secondary">
52
+ {t('auth.registerSubtitle')}
53
+ </Typography>
54
+ </Stack>
55
+
56
+ <Box component="form" onSubmit={handleSubmit} autoComplete="on">
57
+ <TextField
58
+ label={t('auth.email')}
59
+ type="email"
60
+ autoComplete="email"
61
+ required
62
+ fullWidth
63
+ margin="normal"
64
+ value={email}
65
+ onChange={(e) => setEmail(e.target.value)}
66
+ />
67
+ <TextField
68
+ label={t('auth.password')}
69
+ type="password"
70
+ autoComplete="new-password"
71
+ required
72
+ fullWidth
73
+ margin="normal"
74
+ value={password}
75
+ onChange={(e) => setPassword(e.target.value)}
76
+ />
77
+ <TextField
78
+ label={t('auth.confirmPassword')}
79
+ type="password"
80
+ autoComplete="new-password"
81
+ required
82
+ fullWidth
83
+ margin="normal"
84
+ value={confirmPassword}
85
+ onChange={(e) => {
86
+ setConfirmPassword(e.target.value);
87
+ setMismatch(false);
88
+ }}
89
+ error={mismatch}
90
+ helperText={mismatch ? t('auth.passwordMismatch') : undefined}
91
+ />
92
+ <Button type="submit" variant="contained" fullWidth disabled={submitting} sx={{ mt: 2 }}>
93
+ {t('auth.register')}
94
+ </Button>
95
+ </Box>
96
+
97
+ <Typography variant="body2" color="text.secondary" textAlign="center">
98
+ {t('auth.switchToLogin')}{' '}
99
+ <Box
100
+ component="span"
101
+ onClick={onSwitchLogin}
102
+ sx={{
103
+ color: 'primary.main',
104
+ cursor: 'pointer',
105
+ '&:hover': { textDecoration: 'underline' },
106
+ }}
107
+ >
108
+ {t('auth.switchToLoginLink')}
109
+ </Box>
110
+ </Typography>
111
+ </Stack>
112
+ );
113
+ }
@@ -39,7 +39,7 @@ wireMuiNotifier();
39
39
  function Root() {
40
40
  const mode = useThemeStore((s) => s.mode);
41
41
  const theme = useMemo(
42
- () => createTheme({ palette: { mode, primary: { main: '#5e60ce' } } }),
42
+ () => createTheme({ palette: { mode, primary: { main: '#22c55e' } } }),
43
43
  [mode],
44
44
  );
45
45
  return (
@@ -1,200 +1,64 @@
1
1
  import { useState } from 'react';
2
- import { createFileRoute, useNavigate } from '@tanstack/react-router';
3
- import {
4
- Box,
5
- Button,
6
- Container,
7
- Divider,
8
- Paper,
9
- Stack,
10
- Tab,
11
- Tabs,
12
- TextField,
13
- Typography,
14
- } from '@mui/material';
15
- import GoogleIcon from '@mui/icons-material/Google';
16
- import GitHubIcon from '@mui/icons-material/GitHub';
17
- import { useTranslation } from 'react-i18next';
18
- import { useAuthStore, useNotify } from '@icore/template-shared';
19
- import { api } from '../main';
2
+ import { createFileRoute } from '@tanstack/react-router';
3
+ import { Box, useMediaQuery, useTheme } from '@mui/material';
4
+ import { AuthBrandPanel } from '../components/auth/AuthBrandPanel';
5
+ import { CheckEmailScreen } from '../components/auth/CheckEmailScreen';
6
+ import { LoginForm } from '../components/auth/LoginForm';
7
+ import { MagicLinkForm } from '../components/auth/MagicLinkForm';
8
+ import { RegisterForm } from '../components/auth/RegisterForm';
20
9
 
21
- type Mode = 'password' | 'magicLinkRequest' | 'magicLinkSent';
10
+ type Mode = 'login' | 'register' | 'magicLink' | 'checkEmail';
22
11
 
23
12
  function LoginPage() {
24
- const { t } = useTranslation();
25
- const navigate = useNavigate();
26
- const notify = useNotify();
27
- const setAuth = useAuthStore((s) => s.setAuth);
13
+ const [mode, setMode] = useState<Mode>('login');
14
+ const [checkEmail, setCheckEmail] = useState('');
15
+ const theme = useTheme();
16
+ const isLg = useMediaQuery(theme.breakpoints.up('lg'));
28
17
 
29
- const [mode, setMode] = useState<Mode>('password');
30
- const [email, setEmail] = useState('');
31
- const [password, setPassword] = useState('');
32
- const [sentEmail, setSentEmail] = useState('');
33
- const [submitting, setSubmitting] = useState(false);
34
-
35
- async function handlePasswordSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
36
- e.preventDefault();
37
- setSubmitting(true);
38
- try {
39
- const session = await api<{
40
- accessToken: string;
41
- refreshToken: string;
42
- user: { id: string; email: string; role?: string };
43
- }>('/auth/login', {
44
- method: 'POST',
45
- headers: { 'Content-Type': 'application/json' },
46
- body: JSON.stringify({ email, password }),
47
- });
48
- setAuth(session);
49
- notify.success(t('auth.login'));
50
- await navigate({ to: '/dashboard' });
51
- } catch (err) {
52
- notify.error(err instanceof Error ? err.message : t('error.unknown'));
53
- } finally {
54
- setSubmitting(false);
55
- }
56
- }
57
-
58
- async function handleMagicLinkSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
59
- e.preventDefault();
60
- setSubmitting(true);
61
- try {
62
- await api('/auth/magic-link', {
63
- method: 'POST',
64
- headers: { 'Content-Type': 'application/json' },
65
- body: JSON.stringify({ email }),
66
- });
67
- setSentEmail(email);
68
- setMode('magicLinkSent');
69
- } catch (err) {
70
- notify.error(err instanceof Error ? err.message : t('error.unknown'));
71
- } finally {
72
- setSubmitting(false);
73
- }
18
+ function handleRegisterSuccess(email: string) {
19
+ setCheckEmail(email);
20
+ setMode('checkEmail');
74
21
  }
75
22
 
76
23
  return (
77
- <Container maxWidth="sm" sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center' }}>
78
- <Paper elevation={3} sx={{ width: '100%', p: 4 }}>
79
- <Typography variant="h5" component="h1" fontWeight={600} mb={0.5}>
80
- {t('auth.login')}
81
- </Typography>
82
- <Typography variant="body2" color="text.secondary" mb={2}>
83
- {t('auth.email')} &amp; {t('auth.password')}
84
- </Typography>
85
-
86
- {mode !== 'magicLinkSent' && (
87
- <>
88
- <Tabs
89
- value={mode}
90
- onChange={(_, v: Mode) => setMode(v)}
91
- variant="fullWidth"
92
- sx={{ mb: 2 }}
93
- >
94
- <Tab label={t('auth.withPassword')} value="password" />
95
- <Tab label={t('auth.withMagicLink')} value="magicLinkRequest" />
96
- </Tabs>
97
- <Stack spacing={1} sx={{ mb: 2 }}>
98
- <Button
99
- variant="outlined"
100
- fullWidth
101
- startIcon={<GoogleIcon />}
102
- onClick={() => window.location.assign('/api/auth/oauth/google')}
103
- >
104
- {t('auth.continueWithGoogle')}
105
- </Button>
106
- <Button
107
- variant="outlined"
108
- fullWidth
109
- startIcon={<GitHubIcon />}
110
- onClick={() => window.location.assign('/api/auth/oauth/github')}
111
- >
112
- {t('auth.continueWithGithub')}
113
- </Button>
114
- </Stack>
115
- <Divider sx={{ mb: 2 }} />
116
- </>
117
- )}
24
+ <Box
25
+ sx={{
26
+ minHeight: '100vh',
27
+ display: 'flex',
28
+ bgcolor: '#020617',
29
+ }}
30
+ >
31
+ {isLg && <AuthBrandPanel />}
118
32
 
119
- {mode === 'password' && (
120
- <Box component="form" onSubmit={handlePasswordSubmit} autoComplete="on">
121
- <TextField
122
- label={t('auth.email')}
123
- type="email"
124
- autoComplete="email"
125
- required
126
- fullWidth
127
- margin="normal"
128
- value={email}
129
- onChange={(e) => setEmail(e.target.value)}
33
+ <Box
34
+ sx={{
35
+ flex: 1,
36
+ display: 'flex',
37
+ alignItems: 'center',
38
+ justifyContent: 'center',
39
+ p: '40px 24px',
40
+ }}
41
+ >
42
+ <Box sx={{ width: '100%', maxWidth: 400 }}>
43
+ {mode === 'login' && (
44
+ <LoginForm
45
+ onSwitchRegister={() => setMode('register')}
46
+ onSwitchMagicLink={() => setMode('magicLink')}
130
47
  />
131
- <TextField
132
- label={t('auth.password')}
133
- type="password"
134
- autoComplete="current-password"
135
- required
136
- fullWidth
137
- margin="normal"
138
- value={password}
139
- onChange={(e) => setPassword(e.target.value)}
48
+ )}
49
+ {mode === 'register' && (
50
+ <RegisterForm
51
+ onSuccess={handleRegisterSuccess}
52
+ onSwitchLogin={() => setMode('login')}
140
53
  />
141
- <Button
142
- type="submit"
143
- variant="contained"
144
- fullWidth
145
- disabled={submitting}
146
- sx={{ mt: 2 }}
147
- >
148
- {t('auth.login')}
149
- </Button>
150
- </Box>
151
- )}
152
-
153
- {mode === 'magicLinkRequest' && (
154
- <Box component="form" onSubmit={handleMagicLinkSubmit} autoComplete="on">
155
- <TextField
156
- label={t('auth.email')}
157
- type="email"
158
- autoComplete="email"
159
- required
160
- fullWidth
161
- margin="normal"
162
- value={email}
163
- onChange={(e) => setEmail(e.target.value)}
164
- />
165
- <Button
166
- type="submit"
167
- variant="contained"
168
- fullWidth
169
- disabled={submitting}
170
- sx={{ mt: 2 }}
171
- >
172
- {t('auth.sendMagicLink')}
173
- </Button>
174
- </Box>
175
- )}
176
-
177
- {mode === 'magicLinkSent' && (
178
- <Stack spacing={2} alignItems="center" textAlign="center">
179
- <Typography variant="h6">{t('auth.magicLinkSent')}</Typography>
180
- <Typography variant="body2" color="text.secondary">
181
- {t('auth.magicLinkSentDescription', { email: sentEmail })}
182
- </Typography>
183
- <Button
184
- variant="outlined"
185
- fullWidth
186
- onClick={() => {
187
- setEmail('');
188
- setSentEmail('');
189
- setMode('magicLinkRequest');
190
- }}
191
- >
192
- {t('auth.magicLinkUseDifferentEmail')}
193
- </Button>
194
- </Stack>
195
- )}
196
- </Paper>
197
- </Container>
54
+ )}
55
+ {mode === 'magicLink' && <MagicLinkForm onSwitchLogin={() => setMode('login')} />}
56
+ {mode === 'checkEmail' && (
57
+ <CheckEmailScreen email={checkEmail} onBack={() => setMode('login')} />
58
+ )}
59
+ </Box>
60
+ </Box>
61
+ </Box>
198
62
  );
199
63
  }
200
64
 
@@ -0,0 +1,52 @@
1
+ import { ShieldCheck, Zap, Globe } from 'lucide-react';
2
+
3
+ const FEATURES = [
4
+ { icon: ShieldCheck, text: 'Auth, storage & payments — wired out of the box' },
5
+ { icon: Zap, text: 'Strategy pattern — swap any provider via env' },
6
+ { icon: Globe, text: 'Multi-language, dark mode, CASL authorization' },
7
+ ];
8
+
9
+ export function AuthBrandPanel() {
10
+ return (
11
+ <div className="relative hidden lg:flex lg:w-2/5 flex-col justify-between p-12 bg-[--color-card] overflow-hidden">
12
+ {/* Green ambient glow */}
13
+ <div
14
+ aria-hidden
15
+ className="pointer-events-none absolute -top-32 -left-32 h-96 w-96 rounded-full bg-[--color-primary]/10 blur-3xl"
16
+ />
17
+ <div
18
+ aria-hidden
19
+ className="pointer-events-none absolute bottom-0 right-0 h-64 w-64 rounded-full bg-[--color-primary]/5 blur-2xl"
20
+ />
21
+
22
+ <div className="relative z-10">
23
+ <div className="flex items-center gap-2 mb-12">
24
+ <div className="flex h-8 w-8 items-center justify-center rounded-md bg-[--color-primary]">
25
+ <span className="text-sm font-bold text-[--color-primary-foreground]">i</span>
26
+ </div>
27
+ <span className="text-lg font-semibold tracking-tight">iCore</span>
28
+ </div>
29
+
30
+ <h2 className="text-3xl font-bold leading-tight mb-4">
31
+ Enterprise-grade
32
+ <br />
33
+ <span className="text-[--color-primary]">full-stack scaffold</span>
34
+ </h2>
35
+ <p className="text-[--color-muted-foreground] text-sm leading-relaxed max-w-xs">
36
+ Nx monorepo · NestJS microservices · React 19. Production-ready from day one.
37
+ </p>
38
+ </div>
39
+
40
+ <ul className="relative z-10 space-y-4">
41
+ {FEATURES.map(({ icon: Icon, text }) => (
42
+ <li key={text} className="flex items-start gap-3">
43
+ <div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[--color-primary]/15">
44
+ <Icon size={11} className="text-[--color-primary]" />
45
+ </div>
46
+ <span className="text-sm text-[--color-muted-foreground]">{text}</span>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,29 @@
1
+ import { MailCheck } from 'lucide-react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button } from '../ui/button';
4
+
5
+ interface CheckEmailScreenProps {
6
+ email: string;
7
+ onBack: () => void;
8
+ }
9
+
10
+ export function CheckEmailScreen({ email, onBack }: CheckEmailScreenProps) {
11
+ const { t } = useTranslation();
12
+
13
+ return (
14
+ <div className="flex flex-col items-center text-center space-y-4 py-4">
15
+ <div className="flex h-14 w-14 items-center justify-center rounded-full bg-[--color-primary]/15">
16
+ <MailCheck size={24} className="text-[--color-primary]" />
17
+ </div>
18
+ <div className="space-y-1">
19
+ <h3 className="text-lg font-semibold">{t('auth.checkEmail')}</h3>
20
+ <p className="text-sm text-[--color-muted-foreground] max-w-xs">
21
+ {t('auth.checkEmailDescription', { email })}
22
+ </p>
23
+ </div>
24
+ <Button type="button" variant="outline" className="w-full cursor-pointer" onClick={onBack}>
25
+ {t('auth.backToLogin')}
26
+ </Button>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,161 @@
1
+ import { SyntheticEvent, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Loader2 } from 'lucide-react';
4
+ import { Button } from '../ui/button';
5
+ import { Input } from '../ui/input';
6
+ import { Label } from '../ui/label';
7
+
8
+ interface LoginFormProps {
9
+ onSuccess: (session: {
10
+ accessToken: string;
11
+ refreshToken: string;
12
+ user: { id: string; email: string; role?: string };
13
+ }) => void;
14
+ onError: (msg: string) => void;
15
+ onSwitchToRegister: () => void;
16
+ onSwitchToMagicLink: () => void;
17
+ api: <T>(path: string, init?: RequestInit) => Promise<T>;
18
+ }
19
+
20
+ export function LoginForm({
21
+ onSuccess,
22
+ onError,
23
+ onSwitchToRegister,
24
+ onSwitchToMagicLink,
25
+ api,
26
+ }: LoginFormProps) {
27
+ const { t } = useTranslation();
28
+ const [email, setEmail] = useState('');
29
+ const [password, setPassword] = useState('');
30
+ const [submitting, setSubmitting] = useState(false);
31
+
32
+ async function handleSubmit(e: SyntheticEvent<HTMLFormElement>) {
33
+ e.preventDefault();
34
+ setSubmitting(true);
35
+ try {
36
+ const session = await api<{
37
+ accessToken: string;
38
+ refreshToken: string;
39
+ user: { id: string; email: string; role?: string };
40
+ }>('/auth/login', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ email, password }),
44
+ });
45
+ onSuccess(session);
46
+ } catch (err) {
47
+ onError(err instanceof Error ? err.message : t('error.unknown'));
48
+ } finally {
49
+ setSubmitting(false);
50
+ }
51
+ }
52
+
53
+ return (
54
+ <div className="space-y-5">
55
+ <div className="space-y-1">
56
+ <h1 className="text-2xl font-bold">{t('auth.loginTitle')}</h1>
57
+ <p className="text-sm text-[--color-muted-foreground]">{t('auth.loginSubtitle')}</p>
58
+ </div>
59
+
60
+ <form onSubmit={handleSubmit} className="space-y-4">
61
+ <div className="space-y-2">
62
+ <Label htmlFor="login-email">{t('auth.email')}</Label>
63
+ <Input
64
+ id="login-email"
65
+ type="email"
66
+ value={email}
67
+ onChange={(e) => setEmail(e.target.value)}
68
+ placeholder="you@example.com"
69
+ required
70
+ autoComplete="email"
71
+ />
72
+ </div>
73
+ <div className="space-y-2">
74
+ <Label htmlFor="login-password">{t('auth.password')}</Label>
75
+ <Input
76
+ id="login-password"
77
+ type="password"
78
+ value={password}
79
+ onChange={(e) => setPassword(e.target.value)}
80
+ required
81
+ autoComplete="current-password"
82
+ />
83
+ </div>
84
+ <Button type="submit" className="w-full cursor-pointer" disabled={submitting}>
85
+ {submitting ? <Loader2 size={16} className="animate-spin" /> : t('auth.login')}
86
+ </Button>
87
+ </form>
88
+
89
+ <div className="relative">
90
+ <div className="absolute inset-0 flex items-center">
91
+ <div className="w-full border-t border-[--color-border]" />
92
+ </div>
93
+ <div className="relative flex justify-center text-xs">
94
+ <span className="bg-[--color-card] px-2 text-[--color-muted-foreground]">
95
+ {t('auth.orContinueWith')}
96
+ </span>
97
+ </div>
98
+ </div>
99
+
100
+ <div className="grid grid-cols-2 gap-3">
101
+ <Button
102
+ type="button"
103
+ variant="outline"
104
+ className="cursor-pointer"
105
+ onClick={() => window.location.assign('/api/auth/oauth/google')}
106
+ >
107
+ <svg viewBox="0 0 24 24" className="size-4 shrink-0" aria-hidden>
108
+ <path
109
+ fill="#4285F4"
110
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
111
+ />
112
+ <path
113
+ fill="#34A853"
114
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
115
+ />
116
+ <path
117
+ fill="#FBBC05"
118
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
119
+ />
120
+ <path
121
+ fill="#EA4335"
122
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
123
+ />
124
+ </svg>
125
+ Google
126
+ </Button>
127
+ <Button
128
+ type="button"
129
+ variant="outline"
130
+ className="cursor-pointer"
131
+ onClick={() => window.location.assign('/api/auth/oauth/github')}
132
+ >
133
+ <svg viewBox="0 0 24 24" className="size-4 shrink-0" aria-hidden fill="currentColor">
134
+ <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
135
+ </svg>
136
+ GitHub
137
+ </Button>
138
+ </div>
139
+
140
+ <div className="text-center text-sm text-[--color-muted-foreground] space-y-1">
141
+ <p>
142
+ {t('auth.switchToRegister')}{' '}
143
+ <button
144
+ type="button"
145
+ onClick={onSwitchToRegister}
146
+ className="text-[--color-primary] font-medium hover:underline cursor-pointer"
147
+ >
148
+ {t('auth.switchToRegisterLink')}
149
+ </button>
150
+ </p>
151
+ <button
152
+ type="button"
153
+ onClick={onSwitchToMagicLink}
154
+ className="text-xs hover:underline cursor-pointer"
155
+ >
156
+ {t('auth.withMagicLink')}
157
+ </button>
158
+ </div>
159
+ </div>
160
+ );
161
+ }