add user page, changing information

This commit is contained in:
Julian 2023-04-26 13:56:59 -04:00
parent a3706faa42
commit d0310ea0eb
Signed by: NotNite
GPG Key ID: BD91A5402CCEB08A
13 changed files with 708 additions and 80 deletions

View File

@ -0,0 +1,73 @@
import { getUser } from "@/auth";
import { getUserInfo, setPassword, validateUser } from "@/ldap";
import { getLogger } from "@/logger";
type RequestBody = {
currentPassword: string;
newPassword: string;
};
export async function POST(request: Request) {
const logger = getLogger("/api/changePassword");
const user = await getUser();
if (user == null) return new Response(null, { status: 401 });
const userInfo = await getUserInfo(user);
if (userInfo == null) {
// no user info = hasn't registered yet
return new Response(null, { status: 401 });
}
const { currentPassword, newPassword } =
(await request.json()) as RequestBody;
if (
currentPassword == undefined ||
typeof currentPassword !== "string" ||
newPassword == undefined ||
typeof newPassword !== "string"
) {
return new Response(
JSON.stringify({
ok: false,
error: "invalidBody"
}),
{ status: 400 }
);
}
const passwordMatches = await validateUser(user.username!, currentPassword);
if (!passwordMatches) {
return new Response(
JSON.stringify({
ok: false,
error: "incorrectPassword"
}),
{ status: 400 }
);
}
if (newPassword.length < 12) {
return new Response(
JSON.stringify({
ok: false,
error: "passwordShort"
}),
{ status: 400 }
);
}
await setPassword(user.username!, newPassword);
return new Response(
JSON.stringify({
ok: true
}),
{
// I would use 204, but Next doesn't like it, so lol
// https://github.com/vercel/next.js/pull/48354
status: 200
}
);
}

View File

@ -1,6 +1,6 @@
import * as ldap from "@/ldap";
import prisma from "@/prisma";
import { getUserFromRequest } from "@/auth";
import { getUser } from "@/auth";
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
import { getLogger } from "@/logger";
@ -15,7 +15,7 @@ type RequestBody = {
export async function POST(request: Request) {
const logger = getLogger("/api/register");
const user = await getUserFromRequest(request);
const user = await getUser();
if (user == null) return new Response(null, { status: 401 });
if (user.username !== null) {

View File

@ -0,0 +1,93 @@
import { getUser } from "@/auth";
import { getUserInfo, updateUser } from "@/ldap";
import { getLogger } from "@/logger";
type RequestBody = {
displayName?: string;
email?: string;
avatarBase64?: string;
};
export async function POST(request: Request) {
const logger = getLogger("/api/update");
const user = await getUser();
if (user == null) return new Response(null, { status: 401 });
const userInfo = await getUserInfo(user);
if (userInfo == null) {
// no user info = hasn't registered yet
return new Response(null, { status: 409 });
}
const { displayName, email, avatarBase64 } =
(await request.json()) as RequestBody;
let changeDisplayName = false;
if (
displayName !== undefined &&
typeof displayName === "string" &&
displayName !== userInfo.displayName
) {
changeDisplayName = true;
}
// TODO: when we implement migadu, make sure to update the redirect
let changeEmail = false;
if (
email !== undefined &&
typeof email === "string" &&
email !== userInfo.email
) {
changeEmail = true;
}
let avatarBuf = undefined;
if (
avatarBase64 !== undefined &&
typeof avatarBase64 === "string" &&
avatarBase64 !== userInfo.avatar
) {
avatarBuf = Buffer.from(avatarBase64, "base64");
if (avatarBuf.length > 1_000_000) {
return new Response(
JSON.stringify({
ok: false,
error: "avatarBig"
}),
{ status: 400 }
);
}
}
if (!changeDisplayName && !changeEmail && !avatarBuf) {
return new Response(null, { status: 200 });
}
await updateUser(
user,
changeDisplayName ? displayName : undefined,
changeEmail ? email : undefined,
avatarBuf ?? undefined
);
logger.info(
{
username: user.username,
displayName: changeDisplayName ? displayName : null,
email: changeEmail ? email : null,
avatar: avatarBuf ? avatarBuf.toString("base64") : null
},
"updated user"
);
return new Response(
JSON.stringify({
ok: true
}),
{
status: 200
}
);
}

View File

@ -4,24 +4,36 @@
initial-value: #2d2a2e;
}
@property --bg-dark {
syntax: "<color>";
inherits: true;
initial-value: #403e41;
}
@property --bg-darker {
syntax: "<color>";
inherits: true;
initial-value: #221f22;
}
@property --fg {
syntax: "<color>";
inherits: true;
initial-value: #fcfcfa;
}
@property --bg-alt {
syntax: "<color>";
inherits: true;
initial-value: #403e41;
}
@property --fg-alt {
@property --fg-dark {
syntax: "<color>";
inherits: true;
initial-value: #727072;
}
@property --fg-darker {
syntax: "<color>";
inherits: true;
initial-value: #5b595c;
}
@property --error {
syntax: "<color>";
inherits: true;
@ -69,7 +81,7 @@ button {
}
input::placeholder {
color: var(--fg-alt);
color: var(--fg-dark);
transition: color var(--theme-transition);
}

View File

@ -0,0 +1,103 @@
.content {
max-width: 500px;
margin: auto;
}
.header {
padding: 1rem;
padding-bottom: 0;
}
.form {
max-width: 500px;
}
.form input[type="submit"] {
padding: 1rem 1.5rem;
font-size: 140%;
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
font-weight: 600;
}
.buttonContainer {
display: flex;
justify-content: center;
margin: 2rem 0;
}
.buttonContainer input:disabled {
cursor: not-allowed;
color: var(--fg-dark);
}
.formRow {
margin: 1rem 0;
display: flex;
flex-direction: row;
justify-content: center;
}
.formRow label {
font-variant: all-small-caps;
font-size: 105%;
width: 100px;
height: 50px;
/* center */
display: flex;
align-items: center;
}
.formVert {
flex-direction: column;
align-items: center;
}
.fancyInput {
padding: 0.5em 1em;
border: none;
border-radius: 0.15rem;
margin: 0.5rem 0;
width: 250px;
display: block;
background: var(--bg-dark);
}
.formRow input[name="avatar"] {
width: 190px;
}
.formRow .avatar {
margin-right: 10px;
border-radius: 10%;
}
.formRow input:disabled {
cursor: not-allowed;
background: var(--bg-darker);
color: var(--fg-darker);
}
.hint {
color: var(--fg-dark);
font-size: 80%;
transition: color var(--theme-transition);
}
.error {
color: var(--error);
font-size: 80%;
transition: color var(--theme-transition);
}
.divider {
width: 400px;
margin: auto;
background-color: var(--fg-darker);
height: 1px;
border: none;
}

283
src/app/me/AboutMe.tsx Normal file
View File

@ -0,0 +1,283 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { UserInfo } from "@/ldap";
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import styles from "./AboutMe.module.css";
const fallbackAvatar = "https://i.clong.biz/i/oc4zjlqr.png";
type UpdateResponse = {
ok: boolean;
error?: string;
};
// TODO skip do your magic
type InputProps = {
label: string;
name: string;
type: HTMLInputTypeAttribute;
error?: string;
displayImage?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
// get console to shut up
const inputProps = { ...props };
delete inputProps.displayImage;
return (
<div className={styles.formRow}>
<label htmlFor={props.id}>{props.label}</label>
{props.displayImage && (
<img
src={props.displayImage}
className={styles.avatar}
alt={"Your avatar"}
width="50px"
height="50px"
/>
)}
<div className={styles.formVert}>
<input {...inputProps} ref={ref} className={styles.fancyInput} />
{props.error != null && <p className={styles.error}>{props.error}</p>}
</div>
</div>
);
});
Input.displayName = "Input";
async function fileAsBase64(f: File) {
const reader = new FileReader();
reader.readAsArrayBuffer(f);
return new Promise<string>((resolve, reject) => {
reader.onload = () => {
const result = reader.result as ArrayBuffer;
const buffer = Buffer.from(result);
resolve(buffer.toString("base64"));
};
reader.onerror = () => reject(reader.error);
});
}
export default function AboutMe({ info }: { info: UserInfo }) {
const displayNameRef = React.useRef<HTMLInputElement>(null);
const emailRef = React.useRef<HTMLInputElement>(null);
const avatarRef = React.useRef<HTMLInputElement>(null);
const submitRef = React.useRef<HTMLInputElement>(null);
const [avatar, setAvatar] = React.useState<string | null>(
info.avatar ?? null
);
const currentPasswordRef = React.useRef<HTMLInputElement>(null);
const newPasswordRef = React.useRef<HTMLInputElement>(null);
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
const submitPasswordRef = React.useRef<HTMLInputElement>(null);
const [incorrectPassword, setIncorrectPassword] = React.useState(false);
const [passwordMismatch, setPasswordMismatch] = React.useState(false);
const [avatarBig, setAvatarBig] = React.useState(false);
return (
<div className={styles.content}>
<h2 className={styles.header}>User information</h2>
<form
onSubmit={async (e) => {
e.preventDefault();
// turn the data uri into just base64
const avatarChanged = avatar !== null && avatar !== info.avatar;
const avatarData = avatarChanged ? avatar?.split(",")[1] : null;
submitRef.current!.disabled = true;
const req = await fetch("/api/update", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName: displayNameRef.current?.value,
email: emailRef.current?.value,
avatar: avatarData
})
});
submitRef.current!.disabled = false;
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "avatarBig":
setAvatarBig(true);
break;
}
}
} catch {
console.error(req);
}
}}
>
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
title="You can't change your username."
/>
<Input
type="text"
name="display-name"
label="Display name"
defaultValue={info.displayName}
ref={displayNameRef}
/>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
ref={emailRef}
/>
{/* why, html gods, why? */}
<input
type="file"
name="avatar"
accept="image/png, image/jpeg"
ref={avatarRef}
style={{ display: "none" }}
/>
<Input
type="button"
value="Choose file"
name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
error={avatarBig ? "Avatar is too big." : undefined}
onClick={() => {
avatarRef.current?.click();
const eventListener = async () => {
avatarRef.current?.removeEventListener("change", eventListener);
const file = avatarRef.current?.files?.[0];
if (file == null) return;
if (file.size > 1_000_000) {
setAvatarBig(true);
return;
} else {
setAvatarBig(false);
}
const b64 = await fileAsBase64(file);
setAvatar(`data:${file.type};base64,${b64}`);
};
avatarRef.current?.addEventListener("change", eventListener);
}}
displayImage={avatar ?? fallbackAvatar}
/>
<div className={styles.formRow}>
<input
type="submit"
value="Save"
ref={submitRef}
className={styles.fancyInput}
/>
</div>
</form>
<hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2>
<form
onSubmit={async (e) => {
e.preventDefault();
setIncorrectPassword(false);
setPasswordMismatch(false);
if (
newPasswordRef.current?.value !== confirmPasswordRef.current?.value
) {
setPasswordMismatch(true);
return;
}
submitPasswordRef.current!.disabled = true;
const req = await fetch("/api/changePassword", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
currentPassword: currentPasswordRef.current?.value,
newPassword: newPasswordRef.current?.value
})
});
submitPasswordRef.current!.disabled = false;
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "incorrectPassword":
setIncorrectPassword(true);
break;
}
}
} catch {
console.error(req);
}
}}
>
<Input
type="password"
name="current-password"
label="Current"
minLength={12}
required
ref={currentPasswordRef}
error={incorrectPassword ? "Incorrect password." : undefined}
/>
<Input
type="password"
name="new-password"
label="New"
minLength={12}
required
ref={newPasswordRef}
/>
<Input
type="password"
name="confirm-password"
label="Confirm"
ref={confirmPasswordRef}
minLength={12}
required
error={passwordMismatch ? "Passwords do not match." : undefined}
/>
<div className={styles.formRow}>
<input
type="submit"
value="Change password"
ref={submitPasswordRef}
className={styles.fancyInput}
/>
</div>
</form>
</div>
);
}

View File

@ -1,11 +1,19 @@
import { getUserFromPage } from "@/auth";
import { getUser } from "@/auth";
import { getUserInfo } from "@/ldap";
import AboutMe from "./AboutMe";
export default async function Page() {
const user = await getUserFromPage();
const user = await getUser();
if (!user) {
return <p>Not logged in</p>;
window.location.href = "/login";
return;
}
return <p>{user.username}</p>;
const info = await getUserInfo(user);
if (info === null) {
window.location.href = "/login";
return;
}
return <AboutMe info={info} />;
}

View File

@ -5,7 +5,7 @@
.form input[type="submit"] {
padding: 1rem 1.5rem;
font-size: 140%;
background: var(--bg-alt);
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
@ -20,7 +20,7 @@
.buttonContainer input:disabled {
cursor: not-allowed;
color: var(--fg-alt);
color: var(--fg-dark);
}
.formRow {
@ -40,11 +40,11 @@
margin: 0.5rem 0;
width: 250px;
display: block;
background: var(--bg-alt);
background: var(--bg-dark);
}
.hint {
color: var(--fg-alt);
color: var(--fg-dark);
font-size: 80%;
transition: color var(--theme-transition);
}

View File

@ -3,7 +3,6 @@
import React, { InputHTMLAttributes } from "react";
import { HTMLInputTypeAttribute } from "react";
import styles from "./RegisterForm.module.css";
import { cookies } from "next/dist/client/components/headers";
type RegisterResponse = {
ok: boolean;
@ -47,7 +46,7 @@ async function fileAsBase64(f: File) {
});
}
export default function RegisterForm({ ticket }: { ticket: string }) {
export default function RegisterForm() {
const usernameRef = React.useRef<HTMLInputElement>(null);
const displayNameRef = React.useRef<HTMLInputElement>(null);
const emailRef = React.useRef<HTMLInputElement>(null);
@ -92,8 +91,7 @@ export default function RegisterForm({ ticket }: { ticket: string }) {
const req = await fetch(`/api/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + ticket
"Content-Type": "application/json"
},
body: JSON.stringify({
username,

View File

@ -6,12 +6,13 @@ export default function Page() {
const cookieStore = cookies();
const ticket = cookieStore.get("ticket");
if (ticket === null) {
return <div>Ticket is null?</div>;
window.location.href = "/";
return;
}
return (
<main className={styles.main}>
<RegisterForm ticket={ticket!.value} />
<RegisterForm />
</main>
);
}

View File

@ -6,15 +6,14 @@ import { getLogger } from "./logger";
const logger = getLogger("auth.ts");
export async function getUserFromRequest(request: Request) {
const authorization = request.headers
.get("authorization")
?.replace("Bearer ", "");
if (authorization === null) return null;
export async function getUser() {
const cookieStore = cookies();
const cookieTicket = cookieStore.get("ticket");
if (cookieTicket === null) return null;
const ticket = await prisma.authTicket.findFirst({
where: {
ticket: authorization
ticket: cookieTicket?.value
}
});
if (ticket === null) return null;
@ -49,26 +48,6 @@ export async function getUserFromRequest(request: Request) {
return user;
}
export async function getUserFromPage() {
const cookieStore = cookies();
const cookieTicket = cookieStore.get("ticket");
if (cookieTicket === null) return null;
const ticket = await prisma.authTicket.findFirst({
where: {
ticket: cookieTicket?.value
}
});
if (ticket === null) return null;
const user = await prisma.user.findFirst({
where: {
id: ticket.userId
}
});
return user;
}
export async function createAuthTicket(username: string) {
let user = await prisma.user.findFirst({
where: {

View File

@ -6,10 +6,15 @@ import Image from "next/image";
type ColorScheme = {
name: string;
bg: string;
bgAlt: string;
bgDark: string;
bgDarker?: string;
fg: string;
fgAlt: string;
fgDark: string;
fgDarker?: string;
error?: string;
warning?: string;
};
@ -18,88 +23,90 @@ const colors: ColorScheme[] = [
{
name: "Monokai Pro",
bg: "#2d2a2e",
bgAlt: "#403e41",
bgDark: "#403e41",
bgDarker: "#221f22",
fg: "#fcfcfa",
fgAlt: "#727072",
fgDark: "#727072",
fgDarker: "#5b595c",
error: "#ff6188",
warning: "#ffd866"
},
{
name: "Gruvbox Dark",
bg: "#282828",
bgAlt: "#3c3836",
bgDark: "#3c3836",
fg: "#ebdbb2",
fgAlt: "#a89984"
fgDark: "#a89984"
},
{
name: "Amora",
bg: "#1a1a1a",
bgAlt: "#171717",
bgDark: "#171717",
fg: "#DEDBEB",
fgAlt: "#5c5c5c"
fgDark: "#5c5c5c"
},
{
name: "Amora Focus",
bg: "#302838",
bgAlt: "#2a2331",
bgDark: "#2a2331",
fg: "#dedbeb",
fgAlt: "#5c5c5c"
fgDark: "#5c5c5c"
},
{
name: "Rosé Pine",
bg: "#191724",
bgAlt: "#26233a",
bgDark: "#26233a",
fg: "#e0def4",
fgAlt: "#908caa"
fgDark: "#908caa"
},
{
name: "Rosé Pine Moon",
bg: "#232136",
bgAlt: "#393552",
bgDark: "#393552",
fg: "#e0def4",
fgAlt: "#908caa"
fgDark: "#908caa"
},
{
name: "Nord",
bg: "#2e3440",
bgAlt: "#3b4252",
bgDark: "#3b4252",
fg: "#eceff4",
fgAlt: "#d8dee9"
fgDark: "#d8dee9"
},
{
name: "lovelace",
bg: "#1d1f28",
bgAlt: "#282a36",
bgDark: "#282a36",
fg: "#fdfdfd",
fgAlt: "#414458"
fgDark: "#414458"
},
{
name: "skyfall",
bg: "#282f37",
bgAlt: "#20262c",
bgDark: "#20262c",
fg: "#f1fcf9",
fgAlt: "#465463"
fgDark: "#465463"
},
{
name: "Catppuccin Frappe",
bg: "#303446",
bgAlt: "#51576d",
bgDark: "#51576d",
fg: "#c6d0f5",
fgAlt: "#a5adce"
fgDark: "#a5adce"
},
{
name: "Catppuccin Macchiato",
bg: "#24273a",
bgAlt: "#494d64",
bgDark: "#494d64",
fg: "#cad3f5",
fgAlt: "#a5adcb"
fgDark: "#a5adcb"
},
{
name: "Catppuccin Mocha",
bg: "#1e1e2e",
bgAlt: "#45475a",
bgDark: "#45475a",
fg: "#cdd6f4",
fgAlt: "#a6adc8"
fgDark: "#a6adc8"
}
];
@ -122,9 +129,9 @@ export default function ColorChanger() {
const fixedColors = {
"--bg": colorScheme.bg,
"--bg-alt": colorScheme.bgAlt,
"--bg-dark": colorScheme.bgDark,
"--fg": colorScheme.fg,
"--fg-alt": colorScheme.fgAlt,
"--fg-dark": colorScheme.fgDark,
"--error": colorScheme.error ?? fallback.error!,
"--warning": colorScheme.warning ?? fallback.warning!
};

View File

@ -3,7 +3,7 @@ import { Client } from "ldapts";
import { gql } from "./__generated__";
import sharp from "sharp";
import { BerWriter } from "asn1";
import prisma from "./prisma";
import { User } from "@prisma/client";
type LLDAPAuthResponse = {
token: string;
@ -14,6 +14,13 @@ type LLDAPRefreshResponse = {
token: string;
};
export type UserInfo = {
username: string;
displayName: string;
email: string;
avatar?: string;
};
let ldapClient: Client | null = null;
async function getLdapClient() {
if (ldapClient === null) {
@ -173,3 +180,67 @@ export async function checkUserExists(username: string) {
(u) => u.id.toLowerCase() === username.toLowerCase()
);
}
export async function getUserInfo(user: User) {
if (user.username === null) return null;
const client = await getGraphQLClient();
const mutation = await client.query({
query: gql(`
query GetUser($userId: String!) {
user(userId: $userId) {
id
email
displayName
avatar
}
}
`),
variables: {
userId: user.username
}
});
const mutationAvatar = mutation.data.user.avatar;
const avatar = mutationAvatar
? `data:image/jpeg;base64,${mutationAvatar}`
: undefined;
const userInfo: UserInfo = {
username: mutation.data.user.id,
displayName: mutation.data.user.displayName,
email: mutation.data.user.email,
avatar
};
return userInfo;
}
export async function updateUser(
user: User,
displayName?: string,
email?: string,
avatar?: Buffer
) {
if (user.username === null) return;
const client = await getGraphQLClient();
const mutation = await client.mutate({
mutation: gql(`
mutation UpdateUser($user: UpdateUserInput!) {
updateUser(user: $user) {
ok
}
}
`),
variables: {
user: {
id: user.username,
displayName,
email,
avatar: avatar ? await ensureJpg(avatar) : undefined
}
}
});
}