@roboticela/devkit 4.0.0 → 4.1.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.
- package/dist/lib/bundled-registry.d.ts +18 -0
- package/dist/lib/bundled-registry.js +82 -0
- package/dist/lib/component-entry.d.ts +16 -0
- package/dist/lib/component-entry.js +1 -0
- package/dist/lib/config.d.ts +2 -0
- package/dist/lib/config.js +3 -1
- package/dist/lib/installer.js +9 -7
- package/dist/lib/registry.d.ts +2 -16
- package/dist/lib/registry.js +10 -0
- package/package.json +4 -2
- package/registry/components/auth/component.json +29 -0
- package/registry/components/auth/nextjs-compact/v1/files/app/api/auth/[...route]/route.ts +140 -0
- package/registry/components/auth/nextjs-compact/v1/files/app/auth/forgot-password/page.tsx +150 -0
- package/registry/components/auth/nextjs-compact/v1/files/app/auth/login/page.tsx +45 -0
- package/registry/components/auth/nextjs-compact/v1/files/app/auth/register/page.tsx +45 -0
- package/registry/components/auth/nextjs-compact/v1/files/app/auth/reset-password/page.tsx +139 -0
- package/registry/components/auth/nextjs-compact/v1/files/components/auth/AuthProvider.tsx +89 -0
- package/registry/components/auth/nextjs-compact/v1/files/components/auth/LoginForm.tsx +123 -0
- package/registry/components/auth/nextjs-compact/v1/files/components/auth/RegisterForm.tsx +106 -0
- package/registry/components/auth/nextjs-compact/v1/files/lib/auth/authService.ts +43 -0
- package/registry/components/auth/nextjs-compact/v1/manifest.json +37 -0
- package/registry/components/auth/vite-express-tauri/v1/files/server/middleware/requireAuth.ts +14 -0
- package/registry/components/auth/vite-express-tauri/v1/files/server/routes/auth.ts +83 -0
- package/registry/components/auth/vite-express-tauri/v1/files/server/services/jwtService.ts +19 -0
- package/registry/components/auth/vite-express-tauri/v1/files/src/contexts/AuthContext.tsx +72 -0
- package/registry/components/auth/vite-express-tauri/v1/manifest.json +47 -0
- package/registry/components/hero-section/component.json +28 -0
- package/registry/components/hero-section/nextjs-compact/v1/variants/centered/files/components/hero/HeroSection.tsx +97 -0
- package/registry/components/hero-section/nextjs-compact/v1/variants/centered/manifest.json +17 -0
- package/registry/components/hero-section/nextjs-compact/v1/variants/gradient-mesh/files/components/hero/HeroSection.tsx +146 -0
- package/registry/components/hero-section/nextjs-compact/v1/variants/gradient-mesh/manifest.json +17 -0
- package/registry/components/hero-section/nextjs-compact/v1/variants/split-image/files/components/hero/HeroSection.tsx +110 -0
- package/registry/components/hero-section/nextjs-compact/v1/variants/split-image/manifest.json +17 -0
- package/registry/components/registry.json +31 -0
- package/schemas/devkit-config.json +63 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, Suspense } from "react";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
function ResetForm() {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
const params = useSearchParams();
|
|
9
|
+
const token = params.get("token") ?? "";
|
|
10
|
+
const [password, setPassword] = useState("");
|
|
11
|
+
const [confirm, setConfirm] = useState("");
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const [error, setError] = useState("");
|
|
14
|
+
|
|
15
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
if (password !== confirm) { setError("Passwords do not match"); return; }
|
|
18
|
+
setLoading(true);
|
|
19
|
+
setError("");
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch("/api/auth/reset-password", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ token, password }),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const d = await res.json();
|
|
28
|
+
throw new Error(d.message || "Failed to reset password");
|
|
29
|
+
}
|
|
30
|
+
router.push("/auth/login?reset=success");
|
|
31
|
+
} catch (err) {
|
|
32
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!token) {
|
|
39
|
+
return (
|
|
40
|
+
<p style={{ color: "var(--color-error)", textAlign: "center" }}>
|
|
41
|
+
Invalid or missing reset token.
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
|
48
|
+
{error && (
|
|
49
|
+
<div
|
|
50
|
+
style={{
|
|
51
|
+
padding: "var(--space-3) var(--space-4)",
|
|
52
|
+
borderRadius: "var(--radius-md)",
|
|
53
|
+
background: "var(--color-error-subtle)",
|
|
54
|
+
color: "var(--color-error-text)",
|
|
55
|
+
fontSize: "var(--text-sm)",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{error}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
{(["New password", "Confirm password"] as const).map((label) => (
|
|
62
|
+
<div key={label} style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
|
63
|
+
<label style={{ fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)", color: "var(--color-text)" }}>
|
|
64
|
+
{label}
|
|
65
|
+
</label>
|
|
66
|
+
<input
|
|
67
|
+
type="password"
|
|
68
|
+
required
|
|
69
|
+
minLength={8}
|
|
70
|
+
value={label === "New password" ? password : confirm}
|
|
71
|
+
onChange={(e) => label === "New password" ? setPassword(e.target.value) : setConfirm(e.target.value)}
|
|
72
|
+
style={{
|
|
73
|
+
padding: "var(--space-3) var(--space-4)",
|
|
74
|
+
borderRadius: "var(--radius-md)",
|
|
75
|
+
border: "1px solid var(--color-border)",
|
|
76
|
+
background: "var(--color-bg)",
|
|
77
|
+
color: "var(--color-text)",
|
|
78
|
+
fontSize: "var(--text-base)",
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
<button
|
|
84
|
+
type="submit"
|
|
85
|
+
disabled={loading}
|
|
86
|
+
style={{
|
|
87
|
+
padding: "var(--space-3) var(--space-4)",
|
|
88
|
+
borderRadius: "var(--radius-md)",
|
|
89
|
+
background: loading ? "var(--color-bg-muted)" : "var(--color-primary)",
|
|
90
|
+
color: "var(--color-primary-text)",
|
|
91
|
+
fontWeight: "var(--weight-semibold)",
|
|
92
|
+
border: "none",
|
|
93
|
+
cursor: loading ? "not-allowed" : "pointer",
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{loading ? "Resetting…" : "Reset password"}
|
|
97
|
+
</button>
|
|
98
|
+
</form>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default function ResetPasswordPage() {
|
|
103
|
+
return (
|
|
104
|
+
<main
|
|
105
|
+
style={{
|
|
106
|
+
minHeight: "100vh",
|
|
107
|
+
display: "flex",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
justifyContent: "center",
|
|
110
|
+
background: "var(--color-bg-subtle)",
|
|
111
|
+
padding: "var(--space-4)",
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<div
|
|
115
|
+
style={{
|
|
116
|
+
width: "100%",
|
|
117
|
+
maxWidth: "420px",
|
|
118
|
+
background: "var(--color-bg)",
|
|
119
|
+
borderRadius: "var(--radius-xl)",
|
|
120
|
+
border: "1px solid var(--color-border)",
|
|
121
|
+
padding: "var(--space-8)",
|
|
122
|
+
boxShadow: "var(--shadow-lg)",
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<div style={{ textAlign: "center", marginBottom: "var(--space-8)" }}>
|
|
126
|
+
<h1 style={{ fontSize: "var(--text-2xl)", fontWeight: "var(--weight-bold)", color: "var(--color-text)" }}>
|
|
127
|
+
Reset password
|
|
128
|
+
</h1>
|
|
129
|
+
<p style={{ marginTop: "var(--space-2)", fontSize: "var(--text-sm)", color: "var(--color-text-muted)" }}>
|
|
130
|
+
Choose a new password for your account.
|
|
131
|
+
</p>
|
|
132
|
+
</div>
|
|
133
|
+
<Suspense fallback={<p>Loading…</p>}>
|
|
134
|
+
<ResetForm />
|
|
135
|
+
</Suspense>
|
|
136
|
+
</div>
|
|
137
|
+
</main>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface User {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name: string;
|
|
9
|
+
avatar?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AuthContextValue {
|
|
13
|
+
user: User | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
login: (email: string, password: string) => Promise<void>;
|
|
16
|
+
register: (name: string, email: string, password: string) => Promise<void>;
|
|
17
|
+
logout: () => Promise<void>;
|
|
18
|
+
refresh: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
22
|
+
|
|
23
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
24
|
+
const [user, setUser] = useState<User | null>(null);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
refresh().finally(() => setLoading(false));
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
async function refresh() {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch("/api/auth/me");
|
|
34
|
+
if (res.ok) {
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
setUser(data.user);
|
|
37
|
+
} else {
|
|
38
|
+
setUser(null);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
setUser(null);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function login(email: string, password: string) {
|
|
46
|
+
const res = await fetch("/api/auth/login", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({ email, password }),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const err = await res.json();
|
|
53
|
+
throw new Error(err.message || "Login failed");
|
|
54
|
+
}
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
setUser(data.user);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function register(name: string, email: string, password: string) {
|
|
60
|
+
const res = await fetch("/api/auth/register", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ name, email, password }),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const err = await res.json();
|
|
67
|
+
throw new Error(err.message || "Registration failed");
|
|
68
|
+
}
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
setUser(data.user);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function logout() {
|
|
74
|
+
await fetch("/api/auth/logout", { method: "POST" });
|
|
75
|
+
setUser(null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<AuthContext.Provider value={{ user, loading, login, register, logout, refresh }}>
|
|
80
|
+
{children}
|
|
81
|
+
</AuthContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useAuth() {
|
|
86
|
+
const ctx = useContext(AuthContext);
|
|
87
|
+
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
|
|
88
|
+
return ctx;
|
|
89
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useAuth } from "./AuthProvider";
|
|
6
|
+
|
|
7
|
+
export function LoginForm() {
|
|
8
|
+
const { login } = useAuth();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [email, setEmail] = useState("");
|
|
11
|
+
const [password, setPassword] = useState("");
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
setError("");
|
|
18
|
+
setLoading(true);
|
|
19
|
+
try {
|
|
20
|
+
await login(email, password);
|
|
21
|
+
router.push("/");
|
|
22
|
+
} catch (err) {
|
|
23
|
+
setError(err instanceof Error ? err.message : "Login failed");
|
|
24
|
+
} finally {
|
|
25
|
+
setLoading(false);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
|
31
|
+
{error && (
|
|
32
|
+
<div
|
|
33
|
+
role="alert"
|
|
34
|
+
style={{
|
|
35
|
+
padding: "var(--space-3) var(--space-4)",
|
|
36
|
+
borderRadius: "var(--radius-md)",
|
|
37
|
+
background: "var(--color-error-subtle)",
|
|
38
|
+
color: "var(--color-error-text)",
|
|
39
|
+
fontSize: "var(--text-sm)",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{error}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
|
47
|
+
<label htmlFor="email" style={{ fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)", color: "var(--color-text)" }}>
|
|
48
|
+
Email
|
|
49
|
+
</label>
|
|
50
|
+
<input
|
|
51
|
+
id="email"
|
|
52
|
+
type="email"
|
|
53
|
+
required
|
|
54
|
+
autoComplete="email"
|
|
55
|
+
value={email}
|
|
56
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
57
|
+
style={{
|
|
58
|
+
padding: "var(--space-3) var(--space-4)",
|
|
59
|
+
borderRadius: "var(--radius-md)",
|
|
60
|
+
border: "1px solid var(--color-border)",
|
|
61
|
+
background: "var(--color-bg)",
|
|
62
|
+
color: "var(--color-text)",
|
|
63
|
+
fontSize: "var(--text-base)",
|
|
64
|
+
outline: "none",
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
|
70
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
71
|
+
<label htmlFor="password" style={{ fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)", color: "var(--color-text)" }}>
|
|
72
|
+
Password
|
|
73
|
+
</label>
|
|
74
|
+
<a href="/auth/forgot-password" style={{ fontSize: "var(--text-sm)", color: "var(--color-primary)" }}>
|
|
75
|
+
Forgot password?
|
|
76
|
+
</a>
|
|
77
|
+
</div>
|
|
78
|
+
<input
|
|
79
|
+
id="password"
|
|
80
|
+
type="password"
|
|
81
|
+
required
|
|
82
|
+
autoComplete="current-password"
|
|
83
|
+
value={password}
|
|
84
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
85
|
+
style={{
|
|
86
|
+
padding: "var(--space-3) var(--space-4)",
|
|
87
|
+
borderRadius: "var(--radius-md)",
|
|
88
|
+
border: "1px solid var(--color-border)",
|
|
89
|
+
background: "var(--color-bg)",
|
|
90
|
+
color: "var(--color-text)",
|
|
91
|
+
fontSize: "var(--text-base)",
|
|
92
|
+
outline: "none",
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<button
|
|
98
|
+
type="submit"
|
|
99
|
+
disabled={loading}
|
|
100
|
+
style={{
|
|
101
|
+
padding: "var(--space-3) var(--space-4)",
|
|
102
|
+
borderRadius: "var(--radius-md)",
|
|
103
|
+
background: loading ? "var(--color-bg-muted)" : "var(--color-primary)",
|
|
104
|
+
color: "var(--color-primary-text)",
|
|
105
|
+
fontSize: "var(--text-base)",
|
|
106
|
+
fontWeight: "var(--weight-semibold)",
|
|
107
|
+
border: "none",
|
|
108
|
+
cursor: loading ? "not-allowed" : "pointer",
|
|
109
|
+
transition: "background var(--transition-fast)",
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{loading ? "Signing in…" : "Sign in"}
|
|
113
|
+
</button>
|
|
114
|
+
|
|
115
|
+
<p style={{ textAlign: "center", fontSize: "var(--text-sm)", color: "var(--color-text-muted)" }}>
|
|
116
|
+
No account?{" "}
|
|
117
|
+
<a href="/auth/register" style={{ color: "var(--color-primary)", fontWeight: "var(--weight-medium)" }}>
|
|
118
|
+
Create one
|
|
119
|
+
</a>
|
|
120
|
+
</p>
|
|
121
|
+
</form>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useAuth } from "./AuthProvider";
|
|
6
|
+
|
|
7
|
+
export function RegisterForm() {
|
|
8
|
+
const { register } = useAuth();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [name, setName] = useState("");
|
|
11
|
+
const [email, setEmail] = useState("");
|
|
12
|
+
const [password, setPassword] = useState("");
|
|
13
|
+
const [error, setError] = useState("");
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
|
|
16
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
setError("");
|
|
19
|
+
setLoading(true);
|
|
20
|
+
try {
|
|
21
|
+
await register(name, email, password);
|
|
22
|
+
router.push("/");
|
|
23
|
+
} catch (err) {
|
|
24
|
+
setError(err instanceof Error ? err.message : "Registration failed");
|
|
25
|
+
} finally {
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const inputStyle: React.CSSProperties = {
|
|
31
|
+
padding: "var(--space-3) var(--space-4)",
|
|
32
|
+
borderRadius: "var(--radius-md)",
|
|
33
|
+
border: "1px solid var(--color-border)",
|
|
34
|
+
background: "var(--color-bg)",
|
|
35
|
+
color: "var(--color-text)",
|
|
36
|
+
fontSize: "var(--text-base)",
|
|
37
|
+
outline: "none",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
|
42
|
+
{error && (
|
|
43
|
+
<div
|
|
44
|
+
role="alert"
|
|
45
|
+
style={{
|
|
46
|
+
padding: "var(--space-3) var(--space-4)",
|
|
47
|
+
borderRadius: "var(--radius-md)",
|
|
48
|
+
background: "var(--color-error-subtle)",
|
|
49
|
+
color: "var(--color-error-text)",
|
|
50
|
+
fontSize: "var(--text-sm)",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{error}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{(["Name", "Email", "Password"] as const).map((field) => (
|
|
58
|
+
<div key={field} style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
|
59
|
+
<label
|
|
60
|
+
htmlFor={field.toLowerCase()}
|
|
61
|
+
style={{ fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)", color: "var(--color-text)" }}
|
|
62
|
+
>
|
|
63
|
+
{field}
|
|
64
|
+
</label>
|
|
65
|
+
<input
|
|
66
|
+
id={field.toLowerCase()}
|
|
67
|
+
type={field === "Password" ? "password" : field === "Email" ? "email" : "text"}
|
|
68
|
+
required
|
|
69
|
+
value={field === "Name" ? name : field === "Email" ? email : password}
|
|
70
|
+
onChange={(e) => {
|
|
71
|
+
if (field === "Name") setName(e.target.value);
|
|
72
|
+
else if (field === "Email") setEmail(e.target.value);
|
|
73
|
+
else setPassword(e.target.value);
|
|
74
|
+
}}
|
|
75
|
+
style={inputStyle}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
|
|
80
|
+
<button
|
|
81
|
+
type="submit"
|
|
82
|
+
disabled={loading}
|
|
83
|
+
style={{
|
|
84
|
+
padding: "var(--space-3) var(--space-4)",
|
|
85
|
+
borderRadius: "var(--radius-md)",
|
|
86
|
+
background: loading ? "var(--color-bg-muted)" : "var(--color-primary)",
|
|
87
|
+
color: "var(--color-primary-text)",
|
|
88
|
+
fontSize: "var(--text-base)",
|
|
89
|
+
fontWeight: "var(--weight-semibold)",
|
|
90
|
+
border: "none",
|
|
91
|
+
cursor: loading ? "not-allowed" : "pointer",
|
|
92
|
+
transition: "background var(--transition-fast)",
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
{loading ? "Creating account…" : "Create account"}
|
|
96
|
+
</button>
|
|
97
|
+
|
|
98
|
+
<p style={{ textAlign: "center", fontSize: "var(--text-sm)", color: "var(--color-text-muted)" }}>
|
|
99
|
+
Already have an account?{" "}
|
|
100
|
+
<a href="/auth/login" style={{ color: "var(--color-primary)", fontWeight: "var(--weight-medium)" }}>
|
|
101
|
+
Sign in
|
|
102
|
+
</a>
|
|
103
|
+
</p>
|
|
104
|
+
</form>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
|
|
3
|
+
const ACCESS_SECRET = new TextEncoder().encode(
|
|
4
|
+
process.env.JWT_ACCESS_SECRET ?? "change-me-in-production-min-32-chars!!"
|
|
5
|
+
);
|
|
6
|
+
const REFRESH_SECRET = new TextEncoder().encode(
|
|
7
|
+
process.env.JWT_REFRESH_SECRET ?? "change-me-refresh-in-production-32chars"
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export interface TokenPayload {
|
|
11
|
+
sub: string;
|
|
12
|
+
email: string;
|
|
13
|
+
iat?: number;
|
|
14
|
+
exp?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function signToken(
|
|
18
|
+
payload: { sub: string; email: string },
|
|
19
|
+
type: "access" | "refresh" = "access"
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const secret = type === "access" ? ACCESS_SECRET : REFRESH_SECRET;
|
|
22
|
+
const expiresIn = type === "access" ? "15m" : "30d";
|
|
23
|
+
return new SignJWT({ email: payload.email })
|
|
24
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
25
|
+
.setSubject(payload.sub)
|
|
26
|
+
.setIssuedAt()
|
|
27
|
+
.setExpirationTime(expiresIn)
|
|
28
|
+
.sign(secret);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function verifyToken(
|
|
32
|
+
token: string,
|
|
33
|
+
type: "access" | "refresh" = "access"
|
|
34
|
+
): Promise<TokenPayload> {
|
|
35
|
+
const secret = type === "access" ? ACCESS_SECRET : REFRESH_SECRET;
|
|
36
|
+
const { payload } = await jwtVerify(token, secret);
|
|
37
|
+
return {
|
|
38
|
+
sub: payload.sub as string,
|
|
39
|
+
email: payload["email"] as string,
|
|
40
|
+
iat: payload.iat,
|
|
41
|
+
exp: payload.exp,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"template": "nextjs-compact",
|
|
4
|
+
"files": [
|
|
5
|
+
{ "source": "files/app/auth/login/page.tsx", "destination": "app/auth/login/page.tsx" },
|
|
6
|
+
{ "source": "files/app/auth/register/page.tsx", "destination": "app/auth/register/page.tsx" },
|
|
7
|
+
{ "source": "files/app/auth/forgot-password/page.tsx", "destination": "app/auth/forgot-password/page.tsx" },
|
|
8
|
+
{ "source": "files/app/auth/reset-password/page.tsx", "destination": "app/auth/reset-password/page.tsx" },
|
|
9
|
+
{ "source": "files/app/api/auth/[...route]/route.ts", "destination": "app/api/auth/[...route]/route.ts" },
|
|
10
|
+
{ "source": "files/components/auth/LoginForm.tsx", "destination": "components/auth/LoginForm.tsx" },
|
|
11
|
+
{ "source": "files/components/auth/RegisterForm.tsx", "destination": "components/auth/RegisterForm.tsx" },
|
|
12
|
+
{ "source": "files/components/auth/AuthProvider.tsx", "destination": "components/auth/AuthProvider.tsx" },
|
|
13
|
+
{ "source": "files/lib/auth/authService.ts", "destination": "lib/auth/authService.ts" }
|
|
14
|
+
],
|
|
15
|
+
"injections": [
|
|
16
|
+
{
|
|
17
|
+
"file": "app/layout.tsx",
|
|
18
|
+
"marker": "{/* devkit:auth:provider */}",
|
|
19
|
+
"wrap": { "open": "<AuthProvider>", "close": "</AuthProvider>" },
|
|
20
|
+
"importLine": "import { AuthProvider } from '@/components/auth/AuthProvider';"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"frontend": ["jsonwebtoken", "js-cookie"],
|
|
25
|
+
"devFrontend": ["@types/js-cookie"]
|
|
26
|
+
},
|
|
27
|
+
"envVars": [
|
|
28
|
+
"JWT_ACCESS_SECRET",
|
|
29
|
+
"JWT_REFRESH_SECRET",
|
|
30
|
+
"GOOGLE_CLIENT_ID",
|
|
31
|
+
"GOOGLE_CLIENT_SECRET",
|
|
32
|
+
"SMTP_HOST",
|
|
33
|
+
"SMTP_PORT",
|
|
34
|
+
"SMTP_USER",
|
|
35
|
+
"SMTP_PASSWORD"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { verifyToken } from "../services/jwtService.js";
|
|
3
|
+
|
|
4
|
+
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
5
|
+
const token = req.headers.authorization?.replace("Bearer ", "");
|
|
6
|
+
if (!token) { res.status(401).json({ message: "Unauthorized" }); return; }
|
|
7
|
+
try {
|
|
8
|
+
const payload = verifyToken(token);
|
|
9
|
+
(req as Request & { user: { id: string; email: string } }).user = { id: payload.sub, email: payload.email };
|
|
10
|
+
next();
|
|
11
|
+
} catch {
|
|
12
|
+
res.status(401).json({ message: "Invalid or expired token" });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import { signToken, verifyToken } from "../services/jwtService.js";
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
|
|
6
|
+
router.post("/login", async (req: Request, res: Response) => {
|
|
7
|
+
const { email, password } = req.body;
|
|
8
|
+
if (!email || !password) { res.status(400).json({ message: "Email and password required" }); return; }
|
|
9
|
+
|
|
10
|
+
// TODO: replace with real DB lookup + bcrypt.compare
|
|
11
|
+
const user = await findUserByCredentials(email, password);
|
|
12
|
+
if (!user) { res.status(401).json({ message: "Invalid email or password" }); return; }
|
|
13
|
+
|
|
14
|
+
const token = await signToken({ sub: user.id, email: user.email });
|
|
15
|
+
res.json({ user, token });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
router.post("/register", async (req: Request, res: Response) => {
|
|
19
|
+
const { name, email, password } = req.body;
|
|
20
|
+
if (!name || !email || !password) { res.status(400).json({ message: "Name, email, and password required" }); return; }
|
|
21
|
+
if (password.length < 8) { res.status(400).json({ message: "Password must be at least 8 characters" }); return; }
|
|
22
|
+
|
|
23
|
+
// TODO: replace with real user creation
|
|
24
|
+
const user = await createUser(name, email, password);
|
|
25
|
+
const token = await signToken({ sub: user.id, email: user.email });
|
|
26
|
+
res.status(201).json({ user, token });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
router.get("/me", async (req: Request, res: Response) => {
|
|
30
|
+
const authHeader = req.headers.authorization;
|
|
31
|
+
const token = authHeader?.replace("Bearer ", "");
|
|
32
|
+
if (!token) { res.status(401).json({ message: "Unauthorized" }); return; }
|
|
33
|
+
try {
|
|
34
|
+
const payload = await verifyToken(token);
|
|
35
|
+
const user = await findUserById(payload.sub);
|
|
36
|
+
if (!user) { res.status(404).json({ message: "User not found" }); return; }
|
|
37
|
+
res.json({ user });
|
|
38
|
+
} catch {
|
|
39
|
+
res.status(401).json({ message: "Invalid token" });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.post("/forgot-password", async (req: Request, res: Response) => {
|
|
44
|
+
const { email } = req.body;
|
|
45
|
+
if (!email) { res.status(400).json({ message: "Email required" }); return; }
|
|
46
|
+
// TODO: generate reset token, store in DB, send email
|
|
47
|
+
await sendPasswordResetEmail(email);
|
|
48
|
+
res.json({ ok: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
router.post("/reset-password", async (req: Request, res: Response) => {
|
|
52
|
+
const { token, password } = req.body;
|
|
53
|
+
if (!token || !password) { res.status(400).json({ message: "Token and password required" }); return; }
|
|
54
|
+
// TODO: verify token, update password in DB
|
|
55
|
+
await resetUserPassword(token, password);
|
|
56
|
+
res.json({ ok: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.post("/logout", (_req: Request, res: Response) => {
|
|
60
|
+
res.clearCookie("refresh_token");
|
|
61
|
+
res.json({ ok: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Stubs (replace with real DB calls) ───────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
async function findUserByCredentials(email: string, _password: string) {
|
|
67
|
+
void email;
|
|
68
|
+
return null as { id: string; name: string; email: string } | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function createUser(name: string, email: string, _password: string) {
|
|
72
|
+
return { id: crypto.randomUUID(), name, email };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function findUserById(id: string) {
|
|
76
|
+
void id;
|
|
77
|
+
return null as { id: string; name: string; email: string } | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function sendPasswordResetEmail(email: string) { void email; }
|
|
81
|
+
async function resetUserPassword(token: string, password: string) { void token; void password; }
|
|
82
|
+
|
|
83
|
+
export default router;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
|
|
3
|
+
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET ?? "change-me-min-32-chars-access!!";
|
|
4
|
+
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET ?? "change-me-min-32-chars-refresh!";
|
|
5
|
+
|
|
6
|
+
export interface TokenPayload { sub: string; email: string; iat?: number; exp?: number }
|
|
7
|
+
|
|
8
|
+
export function signToken(payload: { sub: string; email: string }, type: "access" | "refresh" = "access"): string {
|
|
9
|
+
return jwt.sign(
|
|
10
|
+
{ email: payload.email },
|
|
11
|
+
type === "access" ? ACCESS_SECRET : REFRESH_SECRET,
|
|
12
|
+
{ subject: payload.sub, expiresIn: type === "access" ? "15m" : "30d" }
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function verifyToken(token: string, type: "access" | "refresh" = "access"): TokenPayload {
|
|
17
|
+
const decoded = jwt.verify(token, type === "access" ? ACCESS_SECRET : REFRESH_SECRET) as jwt.JwtPayload;
|
|
18
|
+
return { sub: decoded.sub as string, email: decoded["email"] as string, iat: decoded.iat, exp: decoded.exp };
|
|
19
|
+
}
|