Skip to content

Commit fb57dc6

Browse files
committed
Add login page
Shortcake-Parent: 2026-08-06-add-shadcn
1 parent 242ebd4 commit fb57dc6

16 files changed

Lines changed: 1237 additions & 8 deletions

File tree

backend/dashboard/src/app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { createInertiaApp } from "@inertiajs/react";
22
import { createRoot } from "react-dom/client";
33

44
import Dashboard from "./pages/Dashboard";
5+
import Login from "./pages/Login";
56
import "./styles.css";
67

7-
const pages = { Dashboard };
8+
const pages = { Dashboard, Login };
89

910
createInertiaApp({
1011
resolve: (name) => {

backend/dashboard/src/components/app-sidebar.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { Link } from "@inertiajs/react";
22
import { LayoutDashboard } from "lucide-react";
33

4+
import { NavUser, type NavUserData } from "@/components/nav-user";
5+
46
import {
57
Sidebar,
68
SidebarContent,
9+
SidebarFooter,
710
SidebarGroup,
811
SidebarGroupContent,
912
SidebarGroupLabel,
@@ -14,7 +17,7 @@ import {
1417
SidebarRail,
1518
} from "@/components/ui/sidebar";
1619

17-
export function AppSidebar() {
20+
export function AppSidebar({ user }: { user: NavUserData }) {
1821
return (
1922
<Sidebar collapsible="icon">
2023
<SidebarHeader>
@@ -60,6 +63,10 @@ export function AppSidebar() {
6063
</SidebarGroup>
6164
</SidebarContent>
6265

66+
<SidebarFooter>
67+
<NavUser user={user} />
68+
</SidebarFooter>
69+
6370
<SidebarRail />
6471
</Sidebar>
6572
);
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { useState } from "react";
2+
import type { FormEvent } from "react";
3+
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Card,
7+
CardContent,
8+
CardDescription,
9+
CardHeader,
10+
CardTitle,
11+
} from "@/components/ui/card";
12+
import {
13+
Field,
14+
FieldDescription,
15+
FieldError,
16+
FieldGroup,
17+
FieldLabel,
18+
} from "@/components/ui/field";
19+
import { Input } from "@/components/ui/input";
20+
import { cn } from "@/lib/utils";
21+
22+
const LOGIN_MUTATION = `
23+
mutation DashboardLogin($input: LoginInput!) {
24+
login(input: $input) {
25+
__typename
26+
... on LoginSuccess {
27+
user {
28+
id
29+
}
30+
}
31+
... on LoginErrors {
32+
errors {
33+
email
34+
password
35+
}
36+
}
37+
... on WrongEmailOrPassword {
38+
message
39+
}
40+
}
41+
}
42+
`;
43+
44+
type LoginErrors = {
45+
email: string[];
46+
password: string[];
47+
form: string[];
48+
};
49+
50+
type LoginResponse = {
51+
data?: {
52+
login?:
53+
| { __typename: "LoginSuccess" }
54+
| {
55+
__typename: "LoginErrors";
56+
errors: { email: string[]; password: string[] };
57+
}
58+
| { __typename: "WrongEmailOrPassword"; message: string };
59+
};
60+
errors?: Array<{ message: string }>;
61+
};
62+
63+
export function LoginForm({
64+
className,
65+
nextUrl,
66+
...props
67+
}: React.ComponentProps<"div"> & { nextUrl: string }) {
68+
const [errors, setErrors] = useState<LoginErrors>({
69+
email: [],
70+
password: [],
71+
form: [],
72+
});
73+
const [isSubmitting, setIsSubmitting] = useState(false);
74+
75+
async function logIn(event: FormEvent<HTMLFormElement>) {
76+
event.preventDefault();
77+
setErrors({ email: [], password: [], form: [] });
78+
setIsSubmitting(true);
79+
80+
const formData = new FormData(event.currentTarget);
81+
82+
try {
83+
const response = await fetch("/graphql", {
84+
method: "POST",
85+
headers: { "Content-Type": "application/json" },
86+
credentials: "same-origin",
87+
body: JSON.stringify({
88+
query: LOGIN_MUTATION,
89+
variables: {
90+
input: {
91+
email: formData.get("email"),
92+
password: formData.get("password"),
93+
},
94+
},
95+
}),
96+
});
97+
98+
if (!response.ok) {
99+
throw new Error(`Login request failed with status ${response.status}`);
100+
}
101+
102+
const payload = (await response.json()) as LoginResponse;
103+
104+
if (payload.errors?.length) {
105+
setErrors({
106+
email: [],
107+
password: [],
108+
form: payload.errors.map((error) => error.message),
109+
});
110+
return;
111+
}
112+
113+
const result = payload.data?.login;
114+
115+
if (result?.__typename === "LoginSuccess") {
116+
window.location.assign(nextUrl);
117+
return;
118+
}
119+
120+
if (result?.__typename === "LoginErrors") {
121+
setErrors({ ...result.errors, form: [] });
122+
return;
123+
}
124+
125+
if (result?.__typename === "WrongEmailOrPassword") {
126+
setErrors({
127+
email: [],
128+
password: ["The email or password is incorrect."],
129+
form: [],
130+
});
131+
return;
132+
}
133+
134+
throw new Error("Login response did not contain a result");
135+
} catch {
136+
setErrors({
137+
email: [],
138+
password: [],
139+
form: ["We couldn't log you in. Please try again."],
140+
});
141+
} finally {
142+
setIsSubmitting(false);
143+
}
144+
}
145+
146+
return (
147+
<div className={cn("flex flex-col gap-6", className)} {...props}>
148+
<Card>
149+
<CardHeader className="text-center">
150+
<CardTitle className="text-xl">Welcome back</CardTitle>
151+
<CardDescription>
152+
Sign in with your email and password.
153+
</CardDescription>
154+
</CardHeader>
155+
<CardContent>
156+
<form onSubmit={logIn}>
157+
<FieldGroup>
158+
<Field data-invalid={errors.email.length > 0}>
159+
<FieldLabel htmlFor="email">Email</FieldLabel>
160+
<Input
161+
aria-describedby={
162+
errors.email.length ? "email-error" : undefined
163+
}
164+
aria-invalid={errors.email.length > 0}
165+
autoComplete="email"
166+
disabled={isSubmitting}
167+
id="email"
168+
name="email"
169+
type="email"
170+
placeholder="m@example.com"
171+
required
172+
/>
173+
<FieldError
174+
errors={errors.email.map((message) => ({ message }))}
175+
id="email-error"
176+
/>
177+
</Field>
178+
<Field data-invalid={errors.password.length > 0}>
179+
<div className="flex items-center">
180+
<FieldLabel htmlFor="password">Password</FieldLabel>
181+
<a
182+
href="/reset-password"
183+
className="ml-auto text-sm underline-offset-4 hover:underline"
184+
>
185+
Forgot your password?
186+
</a>
187+
</div>
188+
<Input
189+
aria-describedby={
190+
errors.password.length ? "password-error" : undefined
191+
}
192+
aria-invalid={errors.password.length > 0}
193+
autoComplete="current-password"
194+
disabled={isSubmitting}
195+
id="password"
196+
name="password"
197+
type="password"
198+
required
199+
/>
200+
<FieldError
201+
errors={errors.password.map((message) => ({ message }))}
202+
id="password-error"
203+
/>
204+
</Field>
205+
<Field>
206+
<Button disabled={isSubmitting} type="submit">
207+
{isSubmitting ? "Logging in…" : "Login"}
208+
</Button>
209+
<FieldError
210+
errors={errors.form.map((message) => ({ message }))}
211+
/>
212+
<FieldDescription className="text-center">
213+
Don&apos;t have an account? <a href="/signup">Sign up</a>
214+
</FieldDescription>
215+
</Field>
216+
</FieldGroup>
217+
</form>
218+
</CardContent>
219+
</Card>
220+
<FieldDescription className="px-6 text-center">
221+
By clicking continue, you agree to our{" "}
222+
<a href="/terms-of-service">Terms of Service</a> and{" "}
223+
<a href="/privacy-policy">Privacy Policy</a>.
224+
</FieldDescription>
225+
</div>
226+
);
227+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { BadgeCheckIcon, ChevronsUpDownIcon, LogOutIcon } from "lucide-react";
2+
3+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuItem,
8+
DropdownMenuLabel,
9+
DropdownMenuSeparator,
10+
DropdownMenuTrigger,
11+
} from "@/components/ui/dropdown-menu";
12+
import {
13+
SidebarMenu,
14+
SidebarMenuButton,
15+
SidebarMenuItem,
16+
useSidebar,
17+
} from "@/components/ui/sidebar";
18+
19+
export type NavUserData = {
20+
name: string;
21+
email: string;
22+
avatar: string | null;
23+
};
24+
25+
function getInitials(name: string) {
26+
return (
27+
name
28+
.split(/\s+/)
29+
.filter(Boolean)
30+
.slice(0, 2)
31+
.map((part) => part[0]?.toUpperCase())
32+
.join("") || "?"
33+
);
34+
}
35+
36+
export function NavUser({ user }: { user: NavUserData }) {
37+
const { isMobile } = useSidebar();
38+
const initials = getInitials(user.name);
39+
40+
async function logOut() {
41+
const response = await fetch("/graphql", {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json" },
44+
body: JSON.stringify({
45+
query: "mutation DashboardLogout { logout { ok } }",
46+
}),
47+
});
48+
49+
if (response.ok) {
50+
window.location.assign("/dashboard/login");
51+
}
52+
}
53+
54+
return (
55+
<SidebarMenu>
56+
<SidebarMenuItem>
57+
<DropdownMenu>
58+
<DropdownMenuTrigger asChild>
59+
<SidebarMenuButton
60+
className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground"
61+
size="lg"
62+
>
63+
<Avatar className="rounded-lg">
64+
{user.avatar ? (
65+
<AvatarImage alt={user.name} src={user.avatar} />
66+
) : null}
67+
<AvatarFallback className="rounded-lg">
68+
{initials}
69+
</AvatarFallback>
70+
</Avatar>
71+
<div className="grid flex-1 text-left text-sm/4">
72+
<div className="truncate font-medium">{user.name}</div>
73+
<div className="truncate text-xs">{user.email}</div>
74+
</div>
75+
<ChevronsUpDownIcon className="ml-auto" />
76+
</SidebarMenuButton>
77+
</DropdownMenuTrigger>
78+
<DropdownMenuContent
79+
align="end"
80+
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
81+
side={isMobile ? "bottom" : "right"}
82+
sideOffset={4}
83+
>
84+
<DropdownMenuLabel className="p-0 font-normal">
85+
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
86+
<Avatar className="rounded-lg">
87+
{user.avatar ? (
88+
<AvatarImage alt={user.name} src={user.avatar} />
89+
) : null}
90+
<AvatarFallback className="rounded-lg">
91+
{initials}
92+
</AvatarFallback>
93+
</Avatar>
94+
<div className="grid flex-1 text-left text-sm/4">
95+
<div className="truncate font-medium">{user.name}</div>
96+
<div className="truncate text-xs">{user.email}</div>
97+
</div>
98+
</div>
99+
</DropdownMenuLabel>
100+
<DropdownMenuSeparator />
101+
<DropdownMenuItem asChild>
102+
<a href="/profile">
103+
<BadgeCheckIcon />
104+
Account
105+
</a>
106+
</DropdownMenuItem>
107+
<DropdownMenuSeparator />
108+
<DropdownMenuItem onSelect={() => void logOut()}>
109+
<LogOutIcon />
110+
Log out
111+
</DropdownMenuItem>
112+
</DropdownMenuContent>
113+
</DropdownMenu>
114+
</SidebarMenuItem>
115+
</SidebarMenu>
116+
);
117+
}

0 commit comments

Comments
 (0)