mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 18:33:01 +00:00
Merge pull request #4 from hex248/development
Login/register modal instead of /login
This commit is contained in:
@@ -24,7 +24,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
|
||||
const [iconPreference, setIconPreference] = useState<IconStyle>("lucide");
|
||||
const [iconPreference, setIconPreference] = useState<IconStyle>("pixel");
|
||||
const [error, setError] = useState("");
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
|
||||
@@ -34,7 +34,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
setName(currentUser.name);
|
||||
setUsername(currentUser.username);
|
||||
setAvatarUrl(currentUser.avatarURL || null);
|
||||
setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide");
|
||||
setIconPreference((currentUser.iconPreference as IconStyle) ?? "pixel");
|
||||
|
||||
setPassword("");
|
||||
setError("");
|
||||
@@ -136,18 +136,18 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start">
|
||||
<SelectItem value="lucide">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="lucide" size={16} />
|
||||
Lucide
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="pixel">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="pixel" size={16} />
|
||||
Pixel
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="lucide">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="lucide" size={16} />
|
||||
Lucide
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="phosphor">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="phosphor" size={16} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/shared";
|
||||
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
@@ -57,6 +57,10 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
||||
const [status, setStatus] = useState<string>(defaultStatus);
|
||||
const [type, setType] = useState<string>(defaultType);
|
||||
useEffect(() => {
|
||||
if (!status && defaultStatus) setStatus(defaultStatus);
|
||||
if (!type && defaultType) setType(defaultType);
|
||||
}, [defaultStatus, defaultType, status, type]);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -19,15 +19,18 @@ const DEMO_USERS = [
|
||||
{ name: "User 2", username: "u2", password: "a" },
|
||||
];
|
||||
|
||||
export default function LogInForm() {
|
||||
export default function LogInForm({
|
||||
showWarning,
|
||||
setShowWarning,
|
||||
}: {
|
||||
showWarning: boolean;
|
||||
setShowWarning: (value: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setUser } = useSession();
|
||||
|
||||
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(() => {
|
||||
return localStorage.getItem("hide-under-construction") !== "true";
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
|
||||
@@ -143,7 +146,7 @@ export default function LogInForm() {
|
||||
<>
|
||||
{/* under construction warning */}
|
||||
{showWarning && (
|
||||
<div className="relative flex flex-col border p-4 items-center border-border/50 bg-border/10 gap-2 max-w-lg">
|
||||
<div className="relative flex flex-col items-center gap-2 max-w-lg">
|
||||
<IconButton
|
||||
size="md"
|
||||
className="absolute top-2 right-2"
|
||||
@@ -168,7 +171,7 @@ export default function LogInForm() {
|
||||
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
|
||||
Login Details
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-xs" showCloseButton={false}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{DEMO_USERS.map((user) => (
|
||||
@@ -208,7 +211,7 @@ export default function LogInForm() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-2 items-center border p-6 pb-4",
|
||||
"relative flex flex-col gap-2 items-center p-4 pb-2",
|
||||
error !== "" && "border-destructive",
|
||||
)}
|
||||
>
|
||||
|
||||
55
packages/frontend/src/components/login-modal.tsx
Normal file
55
packages/frontend/src/components/login-modal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import LogInForm from "@/components/login-form";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }: LoginModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user, isLoading } = useSession();
|
||||
const [hasRedirected, setHasRedirected] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(() => {
|
||||
return localStorage.getItem("hide-under-construction") !== "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !isLoading && user && !hasRedirected) {
|
||||
setHasRedirected(true);
|
||||
const next = searchParams.get("next") || "/issues";
|
||||
navigate(next, { replace: true });
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [open, user, isLoading, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setHasRedirected(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!dismissible && !newOpen) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8", showWarning && "w-md pt-4")}>
|
||||
<DialogTitle className="sr-only">Log In or Register</DialogTitle>
|
||||
<LogInForm showWarning={showWarning} setShowWarning={setShowWarning} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IssueResponse, OrganisationResponse, ProjectResponse } from "@sprint/shared";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
type SelectionContextValue = {
|
||||
selectedOrganisationId: number | null;
|
||||
@@ -69,6 +70,8 @@ const updateUrlParams = (updates: {
|
||||
};
|
||||
|
||||
export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
|
||||
const initialParams = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
|
||||
@@ -153,24 +156,25 @@ export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const allowIssue = window.location.pathname.startsWith("/issues");
|
||||
const pathname = location.pathname;
|
||||
const allowParams = pathname.startsWith("/issues") || pathname.startsWith("/timeline");
|
||||
const updates: {
|
||||
orgSlug?: string | null;
|
||||
projectKey?: string | null;
|
||||
issueNumber?: number | null;
|
||||
} = {};
|
||||
|
||||
if (!params.get("o")) {
|
||||
if (allowParams && !params.get("o")) {
|
||||
const storedOrgSlug = readStoredString("selectedOrganisationSlug");
|
||||
if (storedOrgSlug) updates.orgSlug = storedOrgSlug;
|
||||
}
|
||||
|
||||
if (!params.get("p")) {
|
||||
if (allowParams && !params.get("p")) {
|
||||
const storedProjectKey = readStoredString("selectedProjectKey");
|
||||
if (storedProjectKey) updates.projectKey = storedProjectKey;
|
||||
}
|
||||
|
||||
if (allowIssue && !params.get("i")) {
|
||||
if (allowParams && !params.get("i")) {
|
||||
const storedIssueNumber = readStoredId("selectedIssueNumber");
|
||||
if (storedIssueNumber != null) updates.issueNumber = storedIssueNumber;
|
||||
}
|
||||
@@ -178,7 +182,7 @@ export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateUrlParams(updates);
|
||||
}
|
||||
}, []);
|
||||
}, [location.pathname]);
|
||||
|
||||
const value = useMemo<SelectionContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { UserRecord } from "@sprint/shared";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import Loading from "@/components/loading";
|
||||
import { LoginModal } from "@/components/login-modal";
|
||||
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
interface SessionContextValue {
|
||||
@@ -74,15 +75,22 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useSession();
|
||||
const location = useLocation();
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
setLoginModalOpen(true);
|
||||
} else if (user) {
|
||||
setLoginModalOpen(false);
|
||||
}
|
||||
}, [user, isLoading]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message={"Checking authentication"} />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const next = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} dismissible={false} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -48,13 +48,13 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
<OrganisationSelect
|
||||
noDecoration
|
||||
triggerClassName="px-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
|
||||
triggerClassName="w-8 h-8 ml-1 mr-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
|
||||
trigger={
|
||||
<OrgIcon
|
||||
name={selectedOrganisation?.Organisation.name ?? ""}
|
||||
slug={selectedOrganisation?.Organisation.slug ?? ""}
|
||||
iconURL={selectedOrganisation?.Organisation.iconURL || undefined}
|
||||
size={7}
|
||||
size={8}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -208,7 +208,7 @@ const icons = {
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
export const iconNames = Object.keys(icons) as IconName[];
|
||||
export const iconStyles = ["lucide", "pixel", "phosphor"] as const;
|
||||
export const iconStyles = ["pixel", "lucide", "phosphor"] as const;
|
||||
export type { IconStyle };
|
||||
|
||||
export default function Icon({
|
||||
@@ -227,7 +227,7 @@ export default function Icon({
|
||||
const resolvedStyle = (iconStyle ??
|
||||
session?.user?.iconPreference ??
|
||||
localStorage.getItem("iconPreference") ??
|
||||
"lucide") as IconStyle;
|
||||
"pixel") as IconStyle;
|
||||
const IconComponent = icons[icon]?.[resolvedStyle];
|
||||
|
||||
if (localStorage.getItem("iconPreference") !== resolvedStyle)
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import Font from "@/pages/Font";
|
||||
import Issues from "@/pages/Issues";
|
||||
import Landing from "@/pages/Landing";
|
||||
import Login from "@/pages/Login";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import Test from "@/pages/Test";
|
||||
import Timeline from "@/pages/Timeline";
|
||||
@@ -21,13 +20,12 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<SelectionProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter>
|
||||
<SelectionProvider>
|
||||
<Routes>
|
||||
{/* public routes */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/font" element={<Font />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* authed routes */}
|
||||
<Route
|
||||
@@ -57,10 +55,10 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ActiveTimersOverlay />
|
||||
<Toaster visibleToasts={1} duration={2000} />
|
||||
</SelectionProvider>
|
||||
</SelectionProvider>
|
||||
</BrowserRouter>
|
||||
<ActiveTimersOverlay />
|
||||
<Toaster visibleToasts={1} duration={2000} />
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LoginModal } from "@/components/login-modal";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,7 +25,6 @@ const pricingTiers = [
|
||||
"Email support",
|
||||
],
|
||||
cta: "Get started free",
|
||||
ctaLink: "/login",
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
@@ -45,7 +45,6 @@ const pricingTiers = [
|
||||
"Priority email support",
|
||||
],
|
||||
cta: "Try pro free for 14 days",
|
||||
ctaLink: "/login",
|
||||
highlighted: true,
|
||||
},
|
||||
];
|
||||
@@ -88,6 +87,7 @@ const faqs = [
|
||||
export default function Landing() {
|
||||
const { user, isLoading } = useSession();
|
||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("monthly");
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" id="top">
|
||||
@@ -129,8 +129,8 @@ export default function Landing() {
|
||||
<Link to="/issues">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/login">Sign in</Link>
|
||||
<Button variant="outline" size="sm" onClick={() => setLoginModalOpen(true)}>
|
||||
Sign in
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,8 +162,8 @@ export default function Landing() {
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6">
|
||||
<Link to="/login">Start free trial</Link>
|
||||
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
|
||||
Start free trial
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">
|
||||
<a href="#pricing">See pricing</a>
|
||||
@@ -364,14 +364,14 @@ export default function Landing() {
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant={tier.highlighted ? "default" : "outline"}
|
||||
className={cn(
|
||||
"font-700 py-6",
|
||||
tier.highlighted ? "bg-personality hover:bg-personality/90 text-background" : "",
|
||||
)}
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<Link to={tier.ctaLink}>{tier.cta}</Link>
|
||||
{tier.cta}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -455,8 +455,8 @@ export default function Landing() {
|
||||
<Link to="/issues">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6">
|
||||
<Link to="/login">Start your free trial</Link>
|
||||
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
|
||||
Start your free trial
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -467,6 +467,8 @@ export default function Landing() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
|
||||
<footer className="flex justify-center gap-2 items-center py-1 border-t">
|
||||
<span className="font-300 text-lg text-muted-foreground">
|
||||
Built by{" "}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import Loading from "@/components/loading";
|
||||
import LogInForm from "@/components/login-form";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user, isLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
const next = searchParams.get("next") || "/issues";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
}, [user, isLoading, navigate, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message="Checking authentication" />;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Loading message="Redirecting" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
|
||||
<LogInForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user