built user portal behind auth
This commit is contained in:
parent
5c6962f4a5
commit
37d474d7c8
48 changed files with 822 additions and 496 deletions
|
|
@ -1,116 +1,23 @@
|
|||
"use client";
|
||||
// app/auth/sign-in/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignIn from "./sign-in";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function SignInInner() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const [idVal, setIdVal] = useState("");
|
||||
const [pwVal, setPwVal] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const next = sp.get("next") || "/my/rigs";
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identity: idVal,
|
||||
email: idVal,
|
||||
username: idVal,
|
||||
password: pwVal,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "Login failed");
|
||||
|
||||
router.replace(next);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Login failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
if (at) {
|
||||
redirect("/portal");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Welcome back. Enter your credentials to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{err && (
|
||||
<div className="text-sm rounded border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<Input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder="you@example.com or your-username"
|
||||
value={idVal}
|
||||
onChange={(e) => setIdVal(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={pwVal}
|
||||
onChange={(e) => setPwVal(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
|
||||
<input type="hidden" name="next" value={next} />
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Don’t have an account?{" "}
|
||||
<Link href="/auth/sign-up" className="underline underline-offset-4 hover:opacity-80">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const nextParam = toStr(searchParams?.next) || "/portal";
|
||||
return <SignIn nextPath={nextParam} />;
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignInInner />
|
||||
</Suspense>
|
||||
);
|
||||
function toStr(v: string | string[] | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
|
|
|
|||
101
app/auth/sign-in/sign-in.tsx
Normal file
101
app/auth/sign-in/sign-in.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// app/auth/sign-in/sign-in.tsx
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Props = { nextPath?: string };
|
||||
|
||||
export default function SignIn({ nextPath = "/portal" }: Props) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const next = sp.get("next") || nextPath;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
credentials: "include", // ensure cookie (ma_at) is set
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const txt = await res.text();
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText || "Sign-in failed";
|
||||
try {
|
||||
const j = txt ? JSON.parse(txt) : null;
|
||||
msg = j?.error || j?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
// success → land on next (or /portal)
|
||||
router.replace(next || "/portal");
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Sign-in failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-6 py-10">
|
||||
<h1 className="mb-6 text-2xl font-semibold tracking-tight">Sign In</h1>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex w-full items-center justify-center rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm opacity-70">
|
||||
Don’t have an account?{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={`/auth/sign-up?next=${encodeURIComponent(next || "/portal")}`}
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +1,23 @@
|
|||
"use client";
|
||||
// app/auth/sign-up/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignUp from "./sign-up";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function SignUpInner() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState(""); // optional
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const next = sp.get("next") || "/my/rigs";
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email: email || undefined,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "Registration failed");
|
||||
|
||||
router.replace(next);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Registration failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
export default async function SignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
if (at) {
|
||||
redirect("/portal");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold">Create account</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a username and password. Email is optional (recommended for password reset).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{err && (
|
||||
<div className="text-sm rounded border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<Input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Email <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Without an email, we can’t reset your password if you lose it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
|
||||
<input type="hidden" name="next" value={next} />
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/sign-in" className="underline underline-offset-4 hover:opacity-80">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const nextParam = toStr(searchParams?.next) || "/portal";
|
||||
return <SignUp nextPath={nextParam} />;
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignUpInner />
|
||||
</Suspense>
|
||||
);
|
||||
function toStr(v: string | string[] | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
|
|
|
|||
138
app/auth/sign-up/sign-up.tsx
Normal file
138
app/auth/sign-up/sign-up.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// app/auth/sign-up/sign-up.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
nextPath?: string; // where to go after successful sign-up
|
||||
};
|
||||
|
||||
export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState(""); // optional per your backend flow
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email: email || undefined,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
let j: any = null;
|
||||
try { j = txt ? JSON.parse(txt) : null; } catch {}
|
||||
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
j?.error ||
|
||||
j?.message ||
|
||||
(typeof j === "string" ? j : "") ||
|
||||
`Sign-up failed (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Expect server to create user + set cookies (ma_at, etc.)
|
||||
router.replace(nextPath || "/portal");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign up.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [username, email, password, nextPath, router]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6">
|
||||
<h1 className="mb-1 text-2xl font-semibold">Create Account</h1>
|
||||
<p className="mb-6 text-sm opacity-70">Join MakerDash to manage rigs, settings, and projects.</p>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="your-handle"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email <span className="opacity-60">(optional)</span></label>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="Choose a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Creating account…" : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<span className="opacity-70">Already have an account?</span>{" "}
|
||||
<a className="underline" href={`/auth/sign-in?next=${encodeURIComponent(nextPath)}`}>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue