mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
Merge branch 'master' into development
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<img src="packages/frontend/public/favicon.svg" width="128" />
|
<img src="packages/frontend/public/favicon.svg" width="128" />
|
||||||
|
|
||||||
# Sprint
|
# [Sprint](https://sprintpm.org)
|
||||||
|
|
||||||
Super simple project management tool for developers.
|
Super simple project management tool for developers.
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ STRIPE_SECRET_KEY=your_stripe_secret_key
|
|||||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||||
EMAIL_FROM=Sprint <support@sprintpm.org>
|
EMAIL_FROM=Sprint <support@sprintpm.org>
|
||||||
|
|
||||||
SEED_PASSWORD=replace-in-production
|
SEED_PASSWORD=replace-in-production
|
||||||
|
|||||||
1
packages/backend/drizzle/0029_fantastic_venom.sql
Normal file
1
packages/backend/drizzle/0029_fantastic_venom.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "User" ADD COLUMN "preferences" json DEFAULT '{"assignByDefault":false}'::json NOT NULL;
|
||||||
1361
packages/backend/drizzle/meta/0029_snapshot.json
Normal file
1361
packages/backend/drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,13 @@
|
|||||||
"when": 1769643481882,
|
"when": 1769643481882,
|
||||||
"tag": "0028_quick_supernaut",
|
"tag": "0028_quick_supernaut",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769726204311,
|
||||||
|
"tag": "0029_fantastic_venom",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -95,17 +95,24 @@ const issueComments = [
|
|||||||
"needs product input before proceeding",
|
"needs product input before proceeding",
|
||||||
];
|
];
|
||||||
|
|
||||||
const passwordHash = await hashPassword("a");
|
const SEED_PASSWORD = process.env.SEED_PASSWORD;
|
||||||
|
|
||||||
|
if (!SEED_PASSWORD) {
|
||||||
|
console.error("SEED_PASSWORD is not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(SEED_PASSWORD);
|
||||||
const users = [
|
const users = [
|
||||||
{ name: "user 1", username: "u1", email: "user1@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 1", username: "demo1", email: "demo1@example.com", passwordHash, avatarURL: null },
|
||||||
{ name: "user 2", username: "u2", email: "user2@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 2", username: "demo2", email: "demo2@example.com", passwordHash, avatarURL: null },
|
||||||
// anything past here is just to have more users to assign issues to
|
// anything past here is just to have more users to assign issues to
|
||||||
{ name: "user 3", username: "u3", email: "user3@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 3", username: "demo3", email: "demo3@example.com", passwordHash, avatarURL: null },
|
||||||
{ name: "user 4", username: "u4", email: "user4@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 4", username: "demo4", email: "demo4@example.com", passwordHash, avatarURL: null },
|
||||||
{ name: "user 5", username: "u5", email: "user5@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 5", username: "demo5", email: "demo5@example.com", passwordHash, avatarURL: null },
|
||||||
{ name: "user 6", username: "u6", email: "user6@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 6", username: "demo6", email: "demo6@example.com", passwordHash, avatarURL: null },
|
||||||
{ name: "user 7", username: "u7", email: "user7@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 7", username: "demo7", email: "demo7@example.com", passwordHash, avatarURL: null },
|
||||||
{ name: "user 8", username: "u8", email: "user8@example.com", passwordHash, avatarURL: null },
|
{ name: "demo user 8", username: "demo8", email: "demo8@example.com", passwordHash, avatarURL: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
@@ -312,9 +319,9 @@ async function seed() {
|
|||||||
console.log(`created ${commentValues.length} issue comments`);
|
console.log(`created ${commentValues.length} issue comments`);
|
||||||
|
|
||||||
console.log("database seeding complete");
|
console.log("database seeding complete");
|
||||||
console.log("\ndemo accounts (password: a):");
|
console.log("\ndemo accounts:");
|
||||||
console.log(" - u1");
|
console.log(" - demo1");
|
||||||
console.log(" - u2");
|
console.log(" - demo2");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("failed to seed database:", error);
|
console.error("failed to seed database:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export async function updateById(
|
|||||||
avatarURL?: string | null;
|
avatarURL?: string | null;
|
||||||
iconPreference?: IconStyle;
|
iconPreference?: IconStyle;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
|
preferences?: Record<string, boolean>;
|
||||||
},
|
},
|
||||||
): Promise<UserRecord | undefined> {
|
): Promise<UserRecord | undefined> {
|
||||||
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();
|
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ export default async function me(req: AuthedRequest) {
|
|||||||
user: safeUser as Omit<UserRecord, "passwordHash">,
|
user: safeUser as Omit<UserRecord, "passwordHash">,
|
||||||
csrfToken: req.csrfToken,
|
csrfToken: req.csrfToken,
|
||||||
emailVerified: user.emailVerified,
|
emailVerified: user.emailVerified,
|
||||||
|
preferences: user.preferences,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ export default async function update(req: AuthedRequest) {
|
|||||||
const parsed = await parseJsonBody(req, UserUpdateRequestSchema);
|
const parsed = await parseJsonBody(req, UserUpdateRequestSchema);
|
||||||
if ("error" in parsed) return parsed.error;
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
const { name, password, avatarURL, iconPreference } = parsed.data;
|
const { name, password, avatarURL, iconPreference, preferences } = parsed.data;
|
||||||
|
|
||||||
const user = await getUserById(req.userId);
|
const user = await getUserById(req.userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse("user not found", "USER_NOT_FOUND", 404);
|
return errorResponse("user not found", "USER_NOT_FOUND", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name && !password && avatarURL === undefined && !iconPreference) {
|
if (!name && !password && avatarURL === undefined && !iconPreference && preferences === undefined) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"at least one of name, password, avatarURL, or iconPreference must be provided",
|
"at least one of name, password, avatarURL, iconPreference, or preferences must be provided",
|
||||||
"NO_UPDATES",
|
"NO_UPDATES",
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
@@ -42,7 +42,13 @@ export default async function update(req: AuthedRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { updateById } = await import("../../db/queries/users");
|
const { updateById } = await import("../../db/queries/users");
|
||||||
const updatedUser = await updateById(user.id, { name, passwordHash, avatarURL, iconPreference });
|
const updatedUser = await updateById(user.id, {
|
||||||
|
name,
|
||||||
|
passwordHash,
|
||||||
|
avatarURL,
|
||||||
|
iconPreference,
|
||||||
|
preferences,
|
||||||
|
});
|
||||||
|
|
||||||
if (!updatedUser) {
|
if (!updatedUser) {
|
||||||
return errorResponse("failed to update user", "UPDATE_FAILED", 500);
|
return errorResponse("failed to update user", "UPDATE_FAILED", 500);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Field } from "@/components/ui/field";
|
|||||||
import Icon from "@/components/ui/icon";
|
import Icon from "@/components/ui/icon";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { UploadAvatar } from "@/components/upload-avatar";
|
import { UploadAvatar } from "@/components/upload-avatar";
|
||||||
import { useUpdateUser } from "@/lib/query/hooks";
|
import { useUpdateUser } from "@/lib/query/hooks";
|
||||||
import { parseError } from "@/lib/server";
|
import { parseError } from "@/lib/server";
|
||||||
@@ -29,6 +30,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
|
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
|
||||||
const [iconPreference, setIconPreference] = useState<IconStyle>("pixel");
|
const [iconPreference, setIconPreference] = useState<IconStyle>("pixel");
|
||||||
|
const [preferences, setPreferences] = useState<Record<string, boolean>>({});
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
|||||||
setAvatarUrl(currentUser.avatarURL || null);
|
setAvatarUrl(currentUser.avatarURL || null);
|
||||||
const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE;
|
const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE;
|
||||||
setIconPreference(effectiveIconStyle);
|
setIconPreference(effectiveIconStyle);
|
||||||
|
setPreferences(currentUser.preferences ?? {});
|
||||||
|
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -60,6 +63,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
|||||||
password: password.trim() || undefined,
|
password: password.trim() || undefined,
|
||||||
avatarURL,
|
avatarURL,
|
||||||
iconPreference,
|
iconPreference,
|
||||||
|
preferences,
|
||||||
});
|
});
|
||||||
setError("");
|
setError("");
|
||||||
setUser(data);
|
setUser(data);
|
||||||
@@ -129,6 +133,18 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
|||||||
/>
|
/>
|
||||||
<Label className="text-lg -mt-2">Preferences</Label>
|
<Label className="text-lg -mt-2">Preferences</Label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(preferences.assignByDefault)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setPreferences((prev) => ({ ...prev, assignByDefault: checked }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Assign to me by default</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-8 justify w-full">
|
<div className="flex gap-8 justify w-full">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<Label className="text-sm">Light/Dark Mode</Label>
|
<Label className="text-sm">Light/Dark Mode</Label>
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
|||||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
||||||
const [status, setStatus] = useState<string>(defaultStatus);
|
const [status, setStatus] = useState<string>(defaultStatus);
|
||||||
const [type, setType] = useState<string>(defaultType);
|
const [type, setType] = useState<string>(defaultType);
|
||||||
|
|
||||||
|
// set default assignee based on user preference when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && user.preferences?.assignByDefault) {
|
||||||
|
setAssigneeIds([`${user.id}`]);
|
||||||
|
}
|
||||||
|
}, [open, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!status && defaultStatus) setStatus(defaultStatus);
|
if (!status && defaultStatus) setStatus(defaultStatus);
|
||||||
if (!type && defaultType) setType(defaultType);
|
if (!type && defaultType) setType(defaultType);
|
||||||
|
|||||||
@@ -2,33 +2,16 @@
|
|||||||
|
|
||||||
import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
|
import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Avatar from "@/components/avatar";
|
|
||||||
import { useSession } from "@/components/session-provider";
|
import { useSession } from "@/components/session-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Field } from "@/components/ui/field";
|
import { Field } from "@/components/ui/field";
|
||||||
import Icon from "@/components/ui/icon";
|
|
||||||
import { IconButton } from "@/components/ui/icon-button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { UploadAvatar } from "@/components/upload-avatar";
|
import { UploadAvatar } from "@/components/upload-avatar";
|
||||||
import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
|
import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||||
|
|
||||||
const DEMO_USERS = [
|
export default function LogInForm() {
|
||||||
{ name: "User 1", username: "u1", password: "a" },
|
|
||||||
{ name: "User 2", username: "u2", password: "a" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function LogInForm({
|
|
||||||
showWarning,
|
|
||||||
setShowWarning,
|
|
||||||
}: {
|
|
||||||
showWarning: boolean;
|
|
||||||
setShowWarning: (value: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const { setUser, setEmailVerified } = useSession();
|
const { setUser, setEmailVerified } = useSession();
|
||||||
|
|
||||||
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<"login" | "register">("login");
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -141,189 +124,124 @@ export default function LogInForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
{/* under construction warning */}
|
<form onSubmit={handleSubmit}>
|
||||||
{showWarning && (
|
<div
|
||||||
<div className="relative flex flex-col items-center gap-2 max-w-lg">
|
className={cn(
|
||||||
<IconButton
|
"relative flex flex-col gap-2 items-center p-4 pb-2",
|
||||||
size="md"
|
error !== "" && "border-destructive",
|
||||||
className="absolute top-2 right-2"
|
)}
|
||||||
onClick={() => {
|
>
|
||||||
localStorage.setItem("hide-under-construction", "true");
|
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
|
||||||
setShowWarning(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="x" />
|
|
||||||
</IconButton>
|
|
||||||
<Icon icon="alertTriangle" className="w-16 h-16 text-yellow-500" />
|
|
||||||
<div className="text-center text-sm text-muted-foreground font-500">
|
|
||||||
<p>
|
|
||||||
This application is currently under construction. Your data is very likely to be lost at some
|
|
||||||
point.
|
|
||||||
</p>
|
|
||||||
<p className="font-700 underline underline-offset-3 text-foreground/85 decoration-yellow-500 mt-2">
|
|
||||||
It is not recommended for production use.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2">But you're more than welcome to have a look around!</p>
|
|
||||||
<Dialog open={loginDetailsOpen} onOpenChange={setLoginDetailsOpen}>
|
|
||||||
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
|
|
||||||
Login Details
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent showCloseButton={false}>
|
|
||||||
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{DEMO_USERS.map((user) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={user.username}
|
|
||||||
className="space-y-2 border border-background hover:border-border hover:bg-border/10 cursor-pointer p-2 text-left"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("login");
|
|
||||||
setUsername(user.username);
|
|
||||||
setPassword(user.password);
|
|
||||||
setLoginDetailsOpen(false);
|
|
||||||
resetForm();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar name={user.name} username={user.username} />
|
|
||||||
<span className="font-semibold">{user.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-foreground">Username:</span> {user.username}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-foreground">Password:</span> {user.password}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col gap-2 items-center p-4 pb-2",
|
|
||||||
error !== "" && "border-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
|
|
||||||
|
|
||||||
<div className={"flex flex-col items-center mb-0"}>
|
<div className={"flex flex-col items-center mb-0"}>
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
<>
|
|
||||||
<UploadAvatar
|
|
||||||
name={name}
|
|
||||||
username={username || undefined}
|
|
||||||
avatarURL={avatarURL}
|
|
||||||
onAvatarUploaded={setAvatarUrl}
|
|
||||||
skipOrgCheck
|
|
||||||
className="mb-2"
|
|
||||||
/>
|
|
||||||
{avatarURL && (
|
|
||||||
<Button
|
|
||||||
variant={"dummy"}
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => {
|
|
||||||
setAvatarUrl(null);
|
|
||||||
}}
|
|
||||||
className="-mt-2 mb-2 hover:text-personality"
|
|
||||||
>
|
|
||||||
Remove Avatar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Field
|
|
||||||
label="Full Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
spellcheck={false}
|
|
||||||
maxLength={USER_NAME_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
spellcheck={false}
|
|
||||||
maxLength={USER_EMAIL_MAX_LENGTH}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Field
|
|
||||||
label="Username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
spellcheck={false}
|
|
||||||
maxLength={USER_USERNAME_MAX_LENGTH}
|
|
||||||
showCounter={mode === "register"}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
|
||||||
hidden={true}
|
|
||||||
submitAttempted={submitAttempted}
|
|
||||||
spellcheck={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "login" ? (
|
|
||||||
<>
|
<>
|
||||||
<Button variant={"outline"} type={"submit"}>
|
<UploadAvatar
|
||||||
Log in
|
name={name}
|
||||||
</Button>
|
username={username || undefined}
|
||||||
<Button
|
avatarURL={avatarURL}
|
||||||
className="text-xs hover:text-personality p-0"
|
onAvatarUploaded={setAvatarUrl}
|
||||||
variant={"dummy"}
|
skipOrgCheck
|
||||||
type="button"
|
className="mb-2"
|
||||||
onClick={() => {
|
/>
|
||||||
setMode("register");
|
{avatarURL && (
|
||||||
resetForm();
|
<Button
|
||||||
}}
|
variant={"dummy"}
|
||||||
>
|
type={"button"}
|
||||||
I don't have an account
|
onClick={() => {
|
||||||
</Button>
|
setAvatarUrl(null);
|
||||||
</>
|
}}
|
||||||
) : (
|
className="-mt-2 mb-2 hover:text-personality"
|
||||||
<>
|
>
|
||||||
<Button variant={"outline"} type={"submit"}>
|
Remove Avatar
|
||||||
Register
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
<Button
|
<Field
|
||||||
className="text-xs hover:text-personality p-0"
|
label="Full Name"
|
||||||
variant={"dummy"}
|
value={name}
|
||||||
type="button"
|
onChange={(e) => setName(e.target.value)}
|
||||||
onClick={() => {
|
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||||
setMode("login");
|
submitAttempted={submitAttempted}
|
||||||
resetForm();
|
spellcheck={false}
|
||||||
}}
|
maxLength={USER_NAME_MAX_LENGTH}
|
||||||
>
|
/>
|
||||||
I already have an account
|
<Field
|
||||||
</Button>
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
spellcheck={false}
|
||||||
|
maxLength={USER_EMAIL_MAX_LENGTH}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Field
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
spellcheck={false}
|
||||||
|
maxLength={USER_USERNAME_MAX_LENGTH}
|
||||||
|
showCounter={mode === "register"}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||||
|
hidden={true}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
<div className="flex items-end justify-end w-full text-xs -mb-4">
|
{mode === "login" ? (
|
||||||
{error !== "" ? (
|
<>
|
||||||
<Label className="text-destructive text-sm">{error}</Label>
|
<Button variant={"outline"} type={"submit"}>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="text-xs hover:text-personality p-0"
|
||||||
|
variant={"dummy"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("register");
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I don't have an account
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Label className="opacity-0 text-sm">a</Label>
|
<>
|
||||||
|
<Button variant={"outline"} type={"submit"}>
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="text-xs hover:text-personality p-0"
|
||||||
|
variant={"dummy"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("login");
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I already have an account
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="flex items-end justify-end w-full text-xs -mb-4">
|
||||||
|
{error !== "" ? (
|
||||||
|
<Label className="text-destructive text-sm">{error}</Label>
|
||||||
|
) : (
|
||||||
|
<Label className="opacity-0 text-sm">a</Label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { user, isLoading, emailVerified } = useSession();
|
const { user, isLoading, emailVerified } = useSession();
|
||||||
const [hasRedirected, setHasRedirected] = useState(false);
|
const [hasRedirected, setHasRedirected] = useState(false);
|
||||||
const [showWarning, setShowWarning] = useState(() => {
|
|
||||||
return localStorage.getItem("hide-under-construction") !== "true";
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !isLoading && user && emailVerified && !hasRedirected) {
|
if (open && !isLoading && user && emailVerified && !hasRedirected) {
|
||||||
@@ -46,9 +43,9 @@ export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8", showWarning && "w-md pt-4")}>
|
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8")}>
|
||||||
<DialogTitle className="sr-only">Log In or Register</DialogTitle>
|
<DialogTitle className="sr-only">Log In or Register</DialogTitle>
|
||||||
<LogInForm showWarning={showWarning} setShowWarning={setShowWarning} />
|
<LogInForm />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,17 +62,30 @@ export default function OrgIcon({
|
|||||||
"flex items-center justify-center rounded-sm overflow-hidden",
|
"flex items-center justify-center rounded-sm overflow-hidden",
|
||||||
"text-white font-medium select-none",
|
"text-white font-medium select-none",
|
||||||
!iconURL && backgroundClass,
|
!iconURL && backgroundClass,
|
||||||
`w-${size || 6}`,
|
|
||||||
`h-${size || 6}`,
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={{ width: `calc(var(--spacing) * ${size || 6})`, height: `calc(var(--spacing) * ${size || 6})` }}
|
||||||
>
|
>
|
||||||
{iconURL ? (
|
{iconURL ? (
|
||||||
<img src={iconURL} alt={name} className={`rounded-md object-cover w-${size || 6} h-${size || 6}`} />
|
<img
|
||||||
|
src={iconURL}
|
||||||
|
alt={name}
|
||||||
|
className={`rounded-md object-cover`}
|
||||||
|
style={{
|
||||||
|
width: `calc(var(--spacing) * ${size || 6})`,
|
||||||
|
height: `calc(var(--spacing) * ${size || 6})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn("flex items-center justify-center", `w-${size || 6}`, `h-${size || 6}`)}>
|
<span
|
||||||
<span className={cn("", textClass)}>{getInitials(name)}</span>
|
className={cn("flex items-center justify-center", textClass)}
|
||||||
</div>
|
style={{
|
||||||
|
width: `calc(var(--spacing) * ${size || 6})`,
|
||||||
|
height: `calc(var(--spacing) * ${size || 6})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(name)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ export default function Landing() {
|
|||||||
>
|
>
|
||||||
Pricing
|
Pricing
|
||||||
</a> */}
|
</a> */}
|
||||||
<a
|
{/* <a
|
||||||
href="#faq"
|
href="#faq"
|
||||||
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
|
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
|
||||||
>
|
>
|
||||||
FAQ
|
FAQ
|
||||||
</a>
|
</a> */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{!isLoading && user ? (
|
{!isLoading && user ? (
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ export const UserUpdateRequestSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
avatarURL: z.string().url().nullable().optional(),
|
avatarURL: z.string().url().nullable().optional(),
|
||||||
iconPreference: z.enum(["lucide", "pixel", "phosphor"]).optional(),
|
iconPreference: z.enum(["lucide", "pixel", "phosphor"]).optional(),
|
||||||
|
preferences: z.record(z.boolean()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserUpdateRequest = z.infer<typeof UserUpdateRequestSchema>;
|
export type UserUpdateRequest = z.infer<typeof UserUpdateRequestSchema>;
|
||||||
@@ -431,6 +432,7 @@ export const UserResponseSchema = z.object({
|
|||||||
avatarURL: z.string().nullable(),
|
avatarURL: z.string().nullable(),
|
||||||
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
|
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
|
||||||
plan: z.string().nullable().optional(),
|
plan: z.string().nullable().optional(),
|
||||||
|
preferences: z.record(z.boolean()).optional(),
|
||||||
createdAt: z.string().nullable().optional(),
|
createdAt: z.string().nullable().optional(),
|
||||||
updatedAt: z.string().nullable().optional(),
|
updatedAt: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export const DEFAULT_FEATURES: Record<string, boolean> = {
|
|||||||
sprints: true,
|
sprints: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_USER_PREFERENCES: Record<string, boolean> = {
|
||||||
|
assignByDefault: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const iconStyles = ["pixel", "lucide", "phosphor"] as const;
|
export const iconStyles = ["pixel", "lucide", "phosphor"] as const;
|
||||||
export type IconStyle = (typeof iconStyles)[number];
|
export type IconStyle = (typeof iconStyles)[number];
|
||||||
|
|
||||||
@@ -64,6 +68,10 @@ export const User = pgTable("User", {
|
|||||||
plan: varchar({ length: 32 }).notNull().default("free"),
|
plan: varchar({ length: 32 }).notNull().default("free"),
|
||||||
emailVerified: boolean().notNull().default(false),
|
emailVerified: boolean().notNull().default(false),
|
||||||
emailVerifiedAt: timestamp({ withTimezone: false }),
|
emailVerifiedAt: timestamp({ withTimezone: false }),
|
||||||
|
preferences: json("preferences")
|
||||||
|
.$type<Record<string, boolean>>()
|
||||||
|
.notNull()
|
||||||
|
.default(DEFAULT_USER_PREFERENCES),
|
||||||
createdAt: timestamp({ withTimezone: false }).defaultNow(),
|
createdAt: timestamp({ withTimezone: false }).defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
|
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -227,7 +235,9 @@ export const SessionInsertSchema = createInsertSchema(Session);
|
|||||||
export const TimedSessionSelectSchema = createSelectSchema(TimedSession);
|
export const TimedSessionSelectSchema = createSelectSchema(TimedSession);
|
||||||
export const TimedSessionInsertSchema = createInsertSchema(TimedSession);
|
export const TimedSessionInsertSchema = createInsertSchema(TimedSession);
|
||||||
|
|
||||||
export type UserRecord = z.infer<typeof UserSelectSchema>;
|
export type UserRecord = z.infer<typeof UserSelectSchema> & {
|
||||||
|
preferences: Record<string, boolean>;
|
||||||
|
};
|
||||||
export type UserInsert = z.infer<typeof UserInsertSchema>;
|
export type UserInsert = z.infer<typeof UserInsertSchema>;
|
||||||
|
|
||||||
export type OrganisationRecord = z.infer<typeof OrganisationSelectSchema> & {
|
export type OrganisationRecord = z.infer<typeof OrganisationSelectSchema> & {
|
||||||
|
|||||||
Reference in New Issue
Block a user