Compare commits

..

15 Commits
nix ... main

Author SHA1 Message Date
Julian 940e2621bc
update nix hash 2023-05-10 22:37:38 +00:00
Julian 4ce2931348
add api route for querying user information 2023-05-10 18:29:11 -04:00
Skip R. 33e680a43f hack together change password page
not a modal yet
2023-05-09 19:01:52 -07:00
Skip R. dac227c937 update packages
theres annoying warnings on node 20 that's fixed by doing this
2023-05-09 17:56:36 -07:00
Skip R. 515d874410 (important) shrink checkmark icon to taste
looks better imo
2023-05-09 16:21:20 -07:00
Julian d05961ad15
when that server action hits 2023-05-09 16:26:36 -04:00
Julian 56e11c4d76
fix the logo tooo 2023-05-02 12:16:04 -04:00
Julian 850f4ba9ab
hacky emergency mobile support 2023-05-02 12:14:25 -04:00
Julian fbe2222d1b
thanks naku 2023-04-29 16:32:59 -04:00
Julian 5af2762e12
Force dynamic on OAuth routes
See https://github.com/vercel/next.js/discussions/48989.
2023-04-29 19:18:55 +00:00
Julian 509b4a8f42
it dont work lmfao 2023-04-29 13:12:03 -04:00
Julian 3b11a40928
Merge remote-tracking branch 'origin/nix' 2023-04-29 12:39:58 -04:00
Julian 45decdb110
resize check 2023-04-29 12:12:32 -04:00
Julian 1340bf531a
add back hold-to-unlink 2023-04-28 22:31:38 -04:00
Julian 3e24c99db4
minor annoyances I found on my laptop 2023-04-28 22:16:08 -04:00
30 changed files with 1995 additions and 1468 deletions

1
environment.d.ts vendored
View File

@ -27,6 +27,7 @@ declare global {
GITHUB_ORG: string; GITHUB_ORG: string;
BASE_DOMAIN: string; BASE_DOMAIN: string;
API_TOKEN?: string;
} }
} }
} }

View File

@ -29,7 +29,7 @@
pname = "gluestick"; pname = "gluestick";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
npmDepsHash = "sha256-keOreamXKunlJzU2AKJo0J02ZxQrjLdoCIMCaiwEU4Y="; npmDepsHash = "sha256-JPsXIPyiGycT/4dcg78qAz+qqIRYpSR24NWeu+5jLk0=";
nativeBuildInputs = inputs; nativeBuildInputs = inputs;
buildInputs = inputs; buildInputs = inputs;
@ -148,6 +148,11 @@
export PORT=${toString cfg.port} export PORT=${toString cfg.port}
export NODE_ENV=production export NODE_ENV=production
export DATABASE_URL="file:${cfg.databaseFile}" export DATABASE_URL="file:${cfg.databaseFile}"
set -a
source ${cfg.envFile}
set +a
${pkg}/bin/gluestick ${pkg}/bin/gluestick
''; '';
@ -156,7 +161,6 @@
Group = cfg.group; Group = cfg.group;
Restart = "always"; Restart = "always";
WorkingDirectory = "/var/lib/gluestick"; WorkingDirectory = "/var/lib/gluestick";
EnvironmentFile = cfg.envFile;
}; };
}; };

2540
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,14 +25,15 @@
"formik": "^2.2.9", "formik": "^2.2.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"ldapts": "^4.2.5", "ldapts": "^4.2.5",
"next": "13.3.1", "next": "^13.4.2-canary.4",
"pino": "^8.11.0", "pino": "^8.11.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"sharp": "^0.32.0", "sharp": "^0.32.0",
"typescript": "5.0.4", "typescript": "5.0.4",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"yup": "^1.1.1" "zod": "^3.21.4",
"zod-formik-adapter": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^3.3.1", "@graphql-codegen/cli": "^3.3.1",

View File

@ -0,0 +1,29 @@
"use server";
import { getUser } from "@/auth/auth";
import { getUserInfo, setPassword, validateUser } from "@/ldap";
import { ActionResponse } from ".";
import { PasswordUpdateSchema, passwordUpdateSchema } from "@/schemas";
export default async function changePassword(
data: PasswordUpdateSchema
): Promise<ActionResponse> {
const user = await getUser();
if (user == null) return { ok: false, error: "noUser" };
const userInfo = await getUserInfo(user);
if (userInfo == null) {
return { ok: false, error: "notRegisteredYet" };
}
const { password, newPassword } = passwordUpdateSchema.parse(data);
const passwordMatches = await validateUser(user.username!, password);
if (!passwordMatches) {
return { ok: false, error: "incorrectPassword" };
}
await setPassword(user.username!, newPassword);
return { ok: true };
}

4
src/actions/index.ts Normal file
View File

@ -0,0 +1,4 @@
export type ActionResponse = {
ok: boolean;
error?: string;
};

26
src/actions/login.ts Normal file
View File

@ -0,0 +1,26 @@
"use server";
import * as ldap from "@/ldap";
import { LoginSchema, loginSchema } from "@/schemas";
import { ActionResponse } from ".";
import { getLogger } from "@/logger";
import { authTicketLogin } from "@/auth/auth";
type Response = ActionResponse & {
ticket?: string;
};
export default async function login(data: LoginSchema): Promise<Response> {
const { username, password } = await loginSchema.parse(data);
const valid = await ldap.validateUser(username, password);
if (!valid) {
return {
ok: false,
error: "invalidCredentials"
};
}
const [_, ticket] = await authTicketLogin(username);
return { ok: true, ticket: ticket.ticket };
}

View File

@ -1,38 +1,47 @@
"use server";
import * as ldap from "@/ldap"; import * as ldap from "@/ldap";
import prisma from "@/prisma"; import prisma from "@/prisma";
import { getUser } from "@/auth/auth"; import { getUser } from "@/auth/auth";
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";
import { registerServerSchema } from "@/schemas"; import { RegisterSchema, registerSchema } from "@/schemas";
import { ActionResponse } from ".";
const logger = getLogger("/api/register"); const logger = getLogger("/actions/register");
export async function POST(request: Request) { export default async function register(
data: RegisterSchema
): Promise<ActionResponse> {
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 });
// user already has an account, don't re-register if (user == null) {
return { ok: false, error: "invalidAuth" };
}
if (user.username != null) { if (user.username != null) {
logger.info( logger.info(
{ username: user.username, id: user.id }, { username: user.username, id: user.id },
`user tried to register twice` `user tried to register twice`
); );
return new Response(null, { status: 403 }); return { ok: false, error: "invalidAuth" };
} }
const { username, displayName, email, password, avatar } = const { username, displayName, email, password, avatar } =
await registerServerSchema.validate(await request.json()); await registerSchema.parse(data);
let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null; let avatarBuf = null;
if (avatar != null) {
const parts = avatar.split(",");
const data = parts.length === 2 ? parts[1] : parts[0];
avatarBuf = Buffer.from(data, "base64");
}
const users = await ldap.getUsers(); const users = await ldap.getUsers();
for (const user of users) { for (const user of users) {
if (user.id.toLowerCase() === username.toLowerCase()) { if (user.id.toLowerCase() === username.toLowerCase()) {
return new Response( return {
JSON.stringify({
ok: false, ok: false,
error: "usernameTaken" error: "usernameTaken"
}), };
{ status: 400 }
);
} }
} }
@ -49,10 +58,5 @@ export async function POST(request: Request) {
}); });
logger.info(outputUser, "registered user"); logger.info(outputUser, "registered user");
return new Response( return { ok: true };
JSON.stringify({
ok: true
}),
{ status: 201 }
);
} }

View File

@ -1,3 +1,6 @@
"use server";
import { ValidAuthProvider } from "@/auth/AuthProvider";
import { import {
AuthState, AuthState,
getAuthState, getAuthState,
@ -35,8 +38,7 @@ async function deleteUser(id: number) {
} }
}); });
} }
export default async function unlink(provider?: ValidAuthProvider) {
export async function POST(request: Request) {
const authState = await getAuthState(); const authState = await getAuthState();
if (authState == AuthState.Registering) { if (authState == AuthState.Registering) {
@ -49,30 +51,22 @@ export async function POST(request: Request) {
await deleteUser(registeringUser.id); await deleteUser(registeringUser.id);
return new Response(null, { status: 200 }); return;
} }
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 }); if (user == null) return;
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
switch (provider) { switch (provider) {
case "discord": case "Discord":
const discord = await user.getDiscord(); const discord = await user.getDiscord();
if (discord == null) return new Response(null, { status: 400 }); if (discord == null) return;
await unlinkDiscord(await discord.getId()); await unlinkDiscord(await discord.getId());
break; break;
case "github": case "GitHub":
const github = await user.getGitHub(); const github = await user.getGitHub();
if (github == null) return new Response(null, { status: 400 }); if (github == null) return;
await unlinkGitHub(await github.getId()); await unlinkGitHub(await github.getId());
break; break;
default:
return new Response(null, { status: 400 });
} }
return new Response(null, { status: 200 });
} }

View File

@ -1,30 +1,28 @@
"use server";
import { AboutMeSchema, aboutMeSchema } from "@/schemas";
import { ActionResponse } from ".";
import { getLogger } from "@/logger";
import { getUser } from "@/auth/auth"; import { getUser } from "@/auth/auth";
import { getUserInfo, updateUser } from "@/ldap"; import { getUserInfo, updateUser } from "@/ldap";
import { getLogger } from "@/logger";
type RequestBody = { const logger = getLogger("/actions/update");
displayName?: string;
email?: string;
avatar?: string;
};
export async function POST(request: Request) {
const logger = getLogger("/api/update");
export default async function update(
data: AboutMeSchema
): Promise<ActionResponse> {
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 }); if (user == null) {
return { ok: false, error: "invalidAuth" };
}
const userInfo = await getUserInfo(user); const userInfo = await getUserInfo(user);
if (userInfo == null) { if (userInfo == null) {
// no user info = hasn't registered yet // no user info = hasn't registered yet
return new Response(null, { status: 409 }); return { ok: false, error: "invalidAuth" };
} }
const { const { displayName, email, avatar } = await aboutMeSchema.parse(data);
displayName,
email,
avatar: avatarBase64
} = (await request.json()) as RequestBody;
let changeDisplayName = false; let changeDisplayName = false;
if ( if (
@ -47,25 +45,24 @@ export async function POST(request: Request) {
let avatarBuf = undefined; let avatarBuf = undefined;
if ( if (
avatarBase64 !== undefined && avatar !== undefined &&
typeof avatarBase64 === "string" && typeof avatar === "string" &&
avatarBase64 !== userInfo.avatar avatar !== userInfo.avatar
) { ) {
avatarBuf = Buffer.from(avatarBase64, "base64"); const parts = avatar.split(",");
const data = parts.length === 2 ? parts[1] : parts[0];
avatarBuf = Buffer.from(data, "base64");
if (avatarBuf.length > 2_000_000) { if (avatarBuf.length > 2_000_000) {
return new Response( return {
JSON.stringify({
ok: false, ok: false,
error: "avatarBig" error: "avatarBig"
}), };
{ status: 400 }
);
} }
} }
if (!changeDisplayName && !changeEmail && !avatarBuf) { if (!changeDisplayName && !changeEmail && !avatarBuf) {
return new Response(null, { status: 200 }); return { ok: true };
} }
await updateUser( await updateUser(
@ -85,12 +82,5 @@ export async function POST(request: Request) {
"updated user" "updated user"
); );
return new Response( return { ok: true };
JSON.stringify({
ok: true
}),
{
status: 200
}
);
} }

View File

@ -1,29 +0,0 @@
import { authTicketLogin } from "@/auth/auth";
import * as ldap from "@/ldap";
import { loginSchema } from "@/schemas";
type RequestBody = {
username: string;
password: string;
};
export async function POST(request: Request) {
const { username, password } = await loginSchema.validate(
await request.json()
);
const valid = await ldap.validateUser(username, password);
if (!valid) {
return new Response(
JSON.stringify({
ok: false,
error: "invalidCredentials"
}),
{ status: 401 }
);
}
const [_, ticket] = await authTicketLogin(username);
// not confident if we can set-cookie and I cba to try
return new Response(JSON.stringify({ ok: true, ticket: ticket.ticket }));
}

View File

@ -0,0 +1,41 @@
import { NextRequest } from "next/server";
import prisma from "@/prisma";
import * as ldap from "@/ldap";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: { username: string } }
) {
const { username } = params;
if (
process.env.API_TOKEN == null ||
process.env.API_TOKEN !== request.headers.get("Authorization")
) {
return new Response(null, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { username: username as string }
});
if (user == null) {
return new Response(null, { status: 404 });
}
const ldapUser = await ldap.getUserInfo(user);
if (ldapUser == null) {
return new Response(null, { status: 404 });
}
return new Response(
JSON.stringify({
...ldapUser,
avatar: ldapUser.avatar ?? null,
discordId: ldapUser.discordId ?? null,
githubId: ldapUser.githubId ?? null
})
);
}

View File

@ -48,6 +48,16 @@
:root { :root {
--theme-transition: 0.5s ease; --theme-transition: 0.5s ease;
/* Defined here for Firefox, which doesn't support @property */
--bg: #2d2a2e;
--bg-dark: #403e41;
--bg-darker: #221f22;
--fg: #fcfcfa;
--fg-dark: #727072;
--fg-darker: #5b595c;
--error: #ff6188;
--warning: #ffd866;
} }
* { * {

View File

@ -1,43 +1,28 @@
"use client"; "use client";
import login from "@/actions/login";
import Input from "@/components/Input"; import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import { LoginFormValues, loginSchema } from "@/schemas"; import { LoginSchema, loginSchema } from "@/schemas";
import { Form, Formik, FormikHelpers, FormikValues } from "formik"; import { Form, Formik, FormikHelpers, FormikValues } from "formik";
import React from "react"; import React from "react";
import { toFormikValidationSchema } from "zod-formik-adapter";
export default function LoginForm() { export default function LoginForm() {
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
async function handleFormSubmit( async function handleFormSubmit(
{ username, password }: LoginFormValues, data: LoginSchema,
{ setSubmitting }: FormikHelpers<LoginFormValues> { setSubmitting }: FormikHelpers<LoginSchema>
) { ) {
setSubmitting(true); setSubmitting(true);
if (username === "greets") { if (data.username === "greets") {
window.location.href = "/sekrit"; window.location.href = "/sekrit";
return; return;
} }
const req = await fetch("/api/login", { const res = await login(data);
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username,
password
})
});
try {
const res: {
ok: boolean;
error?: string;
ticket: string;
} = await req.json();
if (res.ok) { if (res.ok) {
document.cookie = `ticket=${res.ticket}; path=/;`; document.cookie = `ticket=${res.ticket}; path=/;`;
window.location.href = "/me"; window.location.href = "/me";
@ -45,11 +30,6 @@ export default function LoginForm() {
// only error is invalidCredentials, I am lazy // only error is invalidCredentials, I am lazy
setGlobalError("Invalid credentials."); setGlobalError("Invalid credentials.");
} }
} catch (err) {
console.error(err);
setGlobalError("shits fucked up yo");
setSubmitting(false);
}
} }
return ( return (
@ -57,7 +37,7 @@ export default function LoginForm() {
<Formik <Formik
initialValues={{ username: "", password: "" }} initialValues={{ username: "", password: "" }}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={loginSchema} validationSchema={toFormikValidationSchema(loginSchema)}
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form> <Form>

View File

@ -7,6 +7,7 @@
display: grid; display: grid;
grid-template-columns: 300px 1fr; grid-template-columns: 300px 1fr;
column-gap: 2rem; column-gap: 2rem;
max-width: 100vw;
} }
.profileTower *:first-child { .profileTower *:first-child {
@ -58,29 +59,6 @@
height: 100%; height: 100%;
} }
/* the !importants here piss me off but it wouldn't accept the property otherwise */
.progress {
background: linear-gradient(
to right,
var(--fg-darker) 50%,
var(--bg-dark) 50%
) !important;
background-size: 200% 100% !important;
background-position: right bottom !important;
transition: all 0s linear !important;
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
}
/* when clicked */
.progress:active {
transition: all 3s linear !important;
background-position: left bottom !important;
}
.multiButtons { .multiButtons {
margin: 1rem 0; margin: 1rem 0;
white-space: nowrap; white-space: nowrap;
@ -94,3 +72,19 @@
width: 100%; width: 100%;
margin: 1rem 0; margin: 1rem 0;
} }
/* stack if we're out of space */
@media (max-width: 800px) {
.profileGrid {
grid-template-columns: 1fr;
}
.profileGrid > * {
max-width: 100vw;
}
.rightGrid {
display: flex;
flex-direction: column;
}
}

View File

@ -5,14 +5,8 @@ import { UserInfo } from "@/ldap";
import React from "react"; import React from "react";
import styles from "./AboutMe.module.css"; import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import Input, { Label } from "@/components/Input"; import Input, { Hint, Label } from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik"; import { Form, Formik, FormikHelpers } from "formik";
import {
AboutMeFormValues,
PasswordUpdateFormValues,
aboutMeSchema,
passwordUpdateSchema
} from "@/schemas";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import Toast from "@/components/Toast"; import Toast from "@/components/Toast";
import { AuthProviderState } from "@/auth/AuthProvider"; import { AuthProviderState } from "@/auth/AuthProvider";
@ -21,11 +15,10 @@ import DiscordIcon from "@/components/icons/DiscordIcon";
import GitHubIcon from "@/components/icons/GitHubIcon"; import GitHubIcon from "@/components/icons/GitHubIcon";
import TailscaleIcon from "@/components/icons/TailscaleIcon"; import TailscaleIcon from "@/components/icons/TailscaleIcon";
import MigaduIcon from "@/components/icons/MigaduIcon"; import MigaduIcon from "@/components/icons/MigaduIcon";
import { AboutMeSchema, aboutMeSchema } from "@/schemas";
type UpdateResponse = { import update from "@/actions/update";
ok: boolean; import { toFormikValidationSchema } from "zod-formik-adapter";
error?: string; import { useRouter } from "next/navigation";
};
export default function AboutMe({ export default function AboutMe({
info, info,
@ -38,8 +31,9 @@ export default function AboutMe({
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const [madeProfileChanges, setMadeChanges] = React.useState(false); const [madeProfileChanges, setMadeChanges] = React.useState(false);
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false); const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
const router = useRouter();
const initialValues: AboutMeFormValues = { const initialValues: AboutMeSchema = {
username: info.username, username: info.username,
displayName: info.displayName, displayName: info.displayName,
email: info.email, email: info.email,
@ -47,78 +41,21 @@ export default function AboutMe({
}; };
async function handleFormSubmit( async function handleFormSubmit(
{ displayName, email, avatar }: AboutMeFormValues, data: AboutMeSchema,
{ setSubmitting }: FormikHelpers<AboutMeFormValues> { setSubmitting }: FormikHelpers<AboutMeSchema>
) { ) {
setMadeChanges(false); setMadeChanges(false);
setSubmitting(true); setSubmitting(true);
const req = await fetch("/api/update", { const res = await update(data);
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName,
email,
avatar: avatar != null ? avatar.split(",")[1] : null
})
});
setSubmitting(false); setSubmitting(false);
try { if (res.ok) {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "avatarBig":
break;
}
}
setMadeChanges(true); setMadeChanges(true);
} catch { } else {
console.error(req); if (res.error != undefined) {
setGlobalError("Unknown error: " + res.error);
} }
} }
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const initialPasswordValues: PasswordUpdateFormValues = {
password: "",
newPassword: "",
confirmPassword: ""
};
async function handlePasswordSubmit(
{ password, newPassword }: PasswordUpdateFormValues,
{ setFieldError, setSubmitting }: FormikHelpers<PasswordUpdateFormValues>
) {
setMadePasswordChanges(false);
setSubmitting(true);
const req = await fetch("/api/changePassword", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
currentPassword: password,
newPassword: newPassword
})
});
setSubmitting(false);
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "incorrectPassword":
setFieldError("password", "Incorrect password.");
break;
}
}
setMadePasswordChanges(true);
} catch {
console.error(req);
}
} }
return ( return (
@ -127,7 +64,7 @@ export default function AboutMe({
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={aboutMeSchema} validationSchema={toFormikValidationSchema(aboutMeSchema)}
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form className={styles.profileGrid}> <Form className={styles.profileGrid}>
@ -150,7 +87,7 @@ export default function AboutMe({
<div> <div>
<h2 className={styles.userName}>{info.username}</h2> <h2 className={styles.userName}>{info.username}</h2>
<div className={styles.rightGrid}> <div className={styles.rightGrid}>
<div> <div className={styles.profile}>
{madeProfileChanges ? ( {madeProfileChanges ? (
<Toast>Saved your changes.</Toast> <Toast>Saved your changes.</Toast>
) : null} ) : null}
@ -185,7 +122,14 @@ export default function AboutMe({
/> />
<div className={styles.multiButtons}> <div className={styles.multiButtons}>
<button type="button">Change Password</button> <button
type="button"
onClick={() => {
router.push("/me/change-password");
}}
>
Change password
</button>
<button <button
type="button" type="button"
@ -204,6 +148,8 @@ export default function AboutMe({
<div className={styles.connections}> <div className={styles.connections}>
<Label>Connections</Label> <Label>Connections</Label>
<Hint>Click to link, hold to unlink.</Hint>
<Connection <Connection
service="Discord" service="Discord"
authState={discordState} authState={discordState}
@ -231,69 +177,6 @@ export default function AboutMe({
)} )}
</Formik> </Formik>
</PrettyForm> </PrettyForm>
{/*<PrettyForm globalError={passwordError}>
<Formik
initialValues={initialPasswordValues}
onSubmit={handlePasswordSubmit}
validationSchema={passwordUpdateSchema}
>
{({ isSubmitting }) => (
<Form>
{madePasswordChanges ? (
<Toast>Changed your password.</Toast>
) : null}
<Input
type="password"
name="password"
label="Current"
minLength={12}
required
/>
<Input
type="password"
name="newPassword"
label="New"
minLength={12}
required
/>
<Input
type="password"
name="confirmPassword"
label="Confirm"
minLength={12}
required
/>
<input
type="submit"
value="Save"
className={styles.fancyInput}
disabled={isSubmitting}
/>
</Form>
)}
</Formik>
</PrettyForm>
<h2 className={styles.header}>Connections</h2>
<div className={styles.authProviderList}>
{providers.map((provider) => (
<AuthProviderEntry provider={provider} key={provider.name} />
))}
</div>
<input
type="button"
value="Log out"
className={styles.logout}
onClick={async () => {
document.cookie =
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/";
}}
/>*/}
</div> </div>
); );
} }

View File

@ -0,0 +1,76 @@
"use client";
import changePassword from "@/actions/changePassword";
import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm";
import { PasswordUpdateSchema, passwordUpdateSchema } from "@/schemas";
import { Form, Formik, FormikHelpers } from "formik";
import { useRouter } from "next/navigation";
import React from "react";
import { toFormikValidationSchema } from "zod-formik-adapter";
export default function ChangePasswordForm({
onSuccess
}: {
onSuccess?: () => void;
}) {
const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter();
const initialValues: PasswordUpdateSchema = {
password: "",
newPassword: "",
confirmPassword: ""
};
async function handleFormSubmit(
data: PasswordUpdateSchema,
helpers: FormikHelpers<PasswordUpdateSchema>
) {
helpers.setSubmitting(true);
setGlobalError(null);
const res = await changePassword(data);
if (!res.ok) {
setGlobalError(res.error!); // should probably make this more human readable :trolley:
} else {
if (onSuccess == null) {
console.log("changed password :3");
router.push("/me");
} else {
onSuccess();
}
}
helpers.setSubmitting(false);
}
return (
<>
<PrettyForm globalError={globalError}>
<Formik
initialValues={initialValues}
onSubmit={handleFormSubmit}
validationSchema={toFormikValidationSchema(passwordUpdateSchema)}
>
{({ isSubmitting }) => (
<Form>
<Input type="password" name="password" label="Current Password" />
<Input type="password" name="newPassword" label="New Password" />
<Input
type="password"
name="confirmPassword"
label="Confirm New Password"
hint="Re-enter your new password. Better safe than sorry!"
/>
<button type="submit" disabled={isSubmitting}>
Change Password
</button>
</Form>
)}
</Formik>
</PrettyForm>
</>
);
}

View File

@ -0,0 +1,11 @@
import ChangePasswordForm from "./ChangePasswordForm";
export default function ChangePassword() {
return (
// fuck it im lazy
<div style={{ maxWidth: "400px", margin: "2rem auto" }}>
<h1>Change Password</h1>
<ChangePasswordForm />
</div>
);
}

View File

@ -1,6 +1,8 @@
import { DiscordAuthProvider } from "@/auth/discord"; import { DiscordAuthProvider } from "@/auth/discord";
import { v4 } from "uuid"; import { v4 } from "uuid";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { export async function GET(request: Request) {
let url = `https://discord.com/oauth2/authorize`; let url = `https://discord.com/oauth2/authorize`;
let state = v4(); let state = v4();

View File

@ -1,6 +1,8 @@
import { GitHubAuthProvider } from "@/auth/github"; import { GitHubAuthProvider } from "@/auth/github";
import { v4 } from "uuid"; import { v4 } from "uuid";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { export async function GET(request: Request) {
let url = `https://github.com/login/oauth/authorize`; let url = `https://github.com/login/oauth/authorize`;
let state = v4(); let state = v4();

View File

@ -2,20 +2,17 @@
import React from "react"; import React from "react";
import styles from "./RegisterForm.module.css"; import styles from "./RegisterForm.module.css";
import { Form, Formik, FormikHelpers, yupToFormErrors } from "formik"; import { Form, Formik, FormikHelpers } from "formik";
import { RegisterFormValues, registerSchema } from "@/schemas"; import { registerSchema, RegisterSchema } from "@/schemas";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { fileAsBase64 } from "@/forms";
import Input from "@/components/Input"; import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import HugeSubmit from "@/components/HugeSubmit"; import HugeSubmit from "@/components/HugeSubmit";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import { ValidAuthProvider } from "@/auth/AuthProvider"; import { ValidAuthProvider } from "@/auth/AuthProvider";
import { toFormikValidationSchema } from "zod-formik-adapter";
type RegisterResponse = { import register from "@/actions/register";
ok: boolean; import unlink from "@/actions/unlink";
error?: string;
};
export default function RegisterForm({ export default function RegisterForm({
initialDisplayName, initialDisplayName,
@ -31,7 +28,7 @@ export default function RegisterForm({
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const initialValues: RegisterFormValues = { const initialValues: RegisterSchema = {
username: "", username: "",
displayName: initialDisplayName ?? "", displayName: initialDisplayName ?? "",
email: initialEmail ?? "", email: initialEmail ?? "",
@ -41,28 +38,12 @@ export default function RegisterForm({
}; };
async function handleFormSubmit( async function handleFormSubmit(
{ avatar, username, displayName, email, password }: RegisterFormValues, data: RegisterSchema,
{ setFieldError, setSubmitting }: FormikHelpers<RegisterFormValues> { setFieldError, setSubmitting }: FormikHelpers<RegisterSchema>
) { ) {
setSubmitting(true); setSubmitting(true);
const resp = await fetch(`/api/register`, { const res = await register(data);
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username,
displayName,
email,
password,
avatar: avatar != null ? avatar.split(",")[1] : undefined
})
});
try {
const res: RegisterResponse = await resp.json();
if (res.ok) { if (res.ok) {
router.replace("/me"); router.replace("/me");
} else { } else {
@ -78,13 +59,13 @@ export default function RegisterForm({
case "usernameTaken": case "usernameTaken":
setFieldError("username", "Username is already taken."); setFieldError("username", "Username is already taken.");
break; break;
default:
setGlobalError("Unknown error: " + res.error);
break;
} }
} }
} }
} catch (err) {
console.error(err);
setGlobalError("you done fucked up kiddo");
}
setSubmitting(false); setSubmitting(false);
} }
@ -94,7 +75,7 @@ export default function RegisterForm({
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={registerSchema} validationSchema={toFormikValidationSchema(registerSchema)}
enableReinitialize enableReinitialize
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
@ -163,7 +144,7 @@ export default function RegisterForm({
<a <a
className={styles.bail} className={styles.bail}
onClick={async () => { onClick={async () => {
await fetch("/api/unlink", { method: "POST" }); await unlink();
document.cookie = document.cookie =
"ticket=; expires=" + new Date().toUTCString() + "; path=/"; "ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/"; window.location.href = "/";

View File

@ -2,7 +2,7 @@ export type ValidAuthProvider = "Discord" | "GitHub";
// Can't send the providers across the wire, do this instead // Can't send the providers across the wire, do this instead
export type AuthProviderState = { export type AuthProviderState = {
name: string; name: ValidAuthProvider;
} & ({ connected: false } | { connected: true; id: string; username: string }); } & ({ connected: false } | { connected: true; id: string; username: string });
export abstract class AuthProvider { export abstract class AuthProvider {

View File

@ -41,7 +41,7 @@ export default function AvatarChanger({
alt="Your avatar" alt="Your avatar"
/> />
) : ( ) : (
<UserIcon /> <UserIcon className={styles.currentAvatar} />
)} )}
<button <button

View File

@ -11,12 +11,17 @@
text-align: left; text-align: left;
} }
.connection svg { .connection .iconContainer > svg {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
margin-left: auto; margin-left: auto;
} }
.connection > svg {
height: 1.5rem;
margin-left: auto;
}
.connection .dot { .connection .dot {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
@ -55,3 +60,21 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* the !importants here piss me off but it wouldn't accept the property otherwise */
.progress {
background: linear-gradient(
to right,
var(--fg-darker) 50%,
var(--bg-dark) 50%
) !important;
background-size: 200% 100% !important;
background-position: right bottom !important;
transition: all 0s linear !important;
}
/* when clicked */
.progress:active {
transition: all 3s linear !important;
background-position: left bottom !important;
}

View File

@ -5,6 +5,7 @@ import classnames from "classnames";
import CheckIcon from "./icons/CheckIcon"; import CheckIcon from "./icons/CheckIcon";
import { type AuthProviderState } from "@/auth/AuthProvider"; import { type AuthProviderState } from "@/auth/AuthProvider";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import unlink from "@/actions/unlink";
export default function Connection({ export default function Connection({
service, service,
@ -18,39 +19,43 @@ export default function Connection({
icon?: () => JSX.Element; icon?: () => JSX.Element;
}) { }) {
const router = useRouter(); const router = useRouter();
const [changing, setChanging] = React.useState(false);
// TODO: Reimplement hold-to-unlink. const holdTime = authState?.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) { const execute = async () => {
event.preventDefault(); const name = authState?.name;
if (!authState?.connected) {
if (unavailable) return; router.push(`/oauth/${name?.toLowerCase()}/login`);
const provider = service.toLowerCase();
if (authState?.connected === false) {
setChanging(true);
router.push(`/oauth/${provider}/login`);
} else { } else {
setChanging(true); await unlink(name);
if (confirm(`Unlink your ${service} account?`)) { router.refresh();
await fetch(`/api/unlink?provider=${provider}`, { method: "POST" });
window.location.reload();
} else {
setChanging(false);
}
}
} }
};
const down = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
interval.current = setTimeout(execute, holdTime);
};
const up = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
if (interval.current) clearTimeout(interval.current);
};
return ( return (
<button <button
type="button"
className={classnames( className={classnames(
styles.connection, styles.connection,
unavailable ? styles.unavailable : null, unavailable ? styles.unavailable : null,
!authState?.connected ? styles.disconnected : null !authState?.connected ? styles.disconnected : styles.progress
)} )}
onClick={handleClick} disabled={unavailable}
disabled={changing} onMouseDown={down}
onMouseUp={up}
onTouchStart={down}
onTouchEnd={up}
> >
<div className={styles.iconContainer}> <div className={styles.iconContainer}>
{icon ? icon() : <span className={styles.dot}></span>} {icon ? icon() : <span className={styles.dot}></span>}

View File

@ -23,6 +23,17 @@ export function Label({
); );
} }
export function Hint({
children,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label className={classnames(styles.hint, props.className)} {...props}>
{children}
</label>
);
}
export default function Input<T>( export default function Input<T>(
props: CustomInputProps<T> & props: CustomInputProps<T> &
FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }> FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }>
@ -63,7 +74,6 @@ export default function Input<T>(
title={props.title} title={props.title}
{...inputFields} {...inputFields}
onChange={(event) => { onChange={(event) => {
console.log(event);
if (props.customOnChange) { if (props.customOnChange) {
console.log("using custom on change"); console.log("using custom on change");
props.customOnChange(event, form); props.customOnChange(event, form);

View File

@ -1,3 +1,3 @@
.logo { .logo {
width: 700px; max-width: 700px;
} }

View File

@ -5,7 +5,7 @@ export default function PrettyForm({
globalError, globalError,
children children
}: { }: {
globalError: string | null; globalError?: string | null;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (

View File

@ -1,12 +1,13 @@
import React from "react"; import React from "react";
export default function UserIcon() { export default function UserIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
viewBox="0 0 128 128" viewBox="0 0 128 128"
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
{...props}
> >
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd"> <g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<circle fill="currentColor" cx="64" cy="48" r="32"></circle> <circle fill="currentColor" cx="64" cy="48" r="32"></circle>

View File

@ -1,107 +1,71 @@
import * as Yup from "yup"; import { z } from "zod";
const REQUIRED = "Required."; const USERNAME = z
const USERNAME = Yup.string() .string()
.required(REQUIRED) .min(1, "Username is too short.")
.min(1, "Username is too short."); .regex(/^[a-z0-9]+$/, "Username must be lowercase alphanumeric.");
const DISPLAY_NAME = Yup.string() const DISPLAY_NAME = z.string().min(1, "Display name is too short.");
.required(REQUIRED) const EMAIL = z.string().email("Not an email.");
.min(1, "Display name is too short."); const PASSWORD = z
const EMAIL = Yup.string().required(REQUIRED).email("Not an email."); .string()
const PASSWORD = Yup.string()
.required(REQUIRED)
.min(12, "Password must be at least 12 characters long."); .min(12, "Password must be at least 12 characters long.");
const CONFIRM_PASSWORD = (name: string) => const AVATAR = z.string().refine(
Yup.string() (val) => {
.required(REQUIRED) const parts = val.split(",");
.oneOf([Yup.ref(name, {})], "Passwords must match."); const data = parts.length === 2 ? parts[1] : parts[0];
const AVATAR = Yup.string().test(
"file-size",
"File is bigger than 2 MB.",
(value) => {
if (value == null) return true;
try { try {
const buf = Buffer.from(value, "base64"); const buf = Buffer.from(data, "base64");
return buf.length <= 2_000_000; return buf.length <= 2_000_000;
} catch (e) { } catch (e) {
return false; return false;
} }
},
{
message: "File is bigger than 2 MB.",
path: ["avatar"]
} }
); );
export const loginSchema = Yup.object().shape({ export const loginSchema = z.object({
username: USERNAME, username: USERNAME,
password: PASSWORD password: PASSWORD
}); });
export type LoginSchema = z.infer<typeof loginSchema>;
export type LoginFormValues = { export const registerSchema = z
username: string; .object({
password: string;
};
export const registerSchema: Yup.Schema<RegisterFormValues> =
Yup.object().shape({
username: USERNAME, username: USERNAME,
displayName: DISPLAY_NAME, displayName: DISPLAY_NAME,
email: EMAIL, email: EMAIL,
password: PASSWORD, password: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("password"), confirmPassword: PASSWORD,
avatar: AVATAR avatar: AVATAR.optional()
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"]
}); });
export interface RegisterFormValues { export type RegisterSchema = z.infer<typeof registerSchema>;
username: string;
displayName: string;
email: string;
password: string;
confirmPassword: string;
avatar?: string;
}
export const aboutMeSchema: Yup.Schema<AboutMeFormValues> = Yup.object().shape({ export const aboutMeSchema = z.object({
username: USERNAME, username: USERNAME,
displayName: DISPLAY_NAME, displayName: DISPLAY_NAME,
email: EMAIL, email: EMAIL,
avatar: AVATAR avatar: AVATAR.optional()
}); });
export type AboutMeSchema = z.infer<typeof aboutMeSchema>;
export interface AboutMeFormValues { export const passwordUpdateSchema = z
username: string; .object({
displayName: string;
email: string;
avatar?: string;
}
export const passwordUpdateSchema: Yup.Schema<PasswordUpdateFormValues> =
Yup.object().shape({
password: PASSWORD, password: PASSWORD,
newPassword: PASSWORD, newPassword: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("newPassword") confirmPassword: PASSWORD
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"]
}); });
export interface PasswordUpdateFormValues { export type PasswordUpdateSchema = z.infer<typeof passwordUpdateSchema>;
password: string;
newPassword: string;
confirmPassword: string;
}
// Types specific to the server, because sometimes we omit fields (like confirmPassword)
export const registerServerSchema: Yup.Schema<RegisterServerFormValues> =
Yup.object().shape({
username: USERNAME,
displayName: DISPLAY_NAME,
email: EMAIL,
password: PASSWORD,
avatar: AVATAR
});
export interface RegisterServerFormValues {
username: string;
displayName: string;
email: string;
password: string;
avatar?: string;
}