diff --git a/src/app/api/changePassword/route.ts b/src/app/api/changePassword/route.ts new file mode 100644 index 0000000..03db8bf --- /dev/null +++ b/src/app/api/changePassword/route.ts @@ -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 + } + ); +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 109f95a..1bb5b01 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -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) { diff --git a/src/app/api/update/route.ts b/src/app/api/update/route.ts new file mode 100644 index 0000000..1d984b5 --- /dev/null +++ b/src/app/api/update/route.ts @@ -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 + } + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 134bffc..ae33bf5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,24 +4,36 @@ initial-value: #2d2a2e; } +@property --bg-dark { + syntax: ""; + inherits: true; + initial-value: #403e41; +} + +@property --bg-darker { + syntax: ""; + inherits: true; + initial-value: #221f22; +} + @property --fg { syntax: ""; inherits: true; initial-value: #fcfcfa; } -@property --bg-alt { - syntax: ""; - inherits: true; - initial-value: #403e41; -} - -@property --fg-alt { +@property --fg-dark { syntax: ""; inherits: true; initial-value: #727072; } +@property --fg-darker { + syntax: ""; + inherits: true; + initial-value: #5b595c; +} + @property --error { syntax: ""; inherits: true; @@ -69,7 +81,7 @@ button { } input::placeholder { - color: var(--fg-alt); + color: var(--fg-dark); transition: color var(--theme-transition); } diff --git a/src/app/me/AboutMe.module.css b/src/app/me/AboutMe.module.css new file mode 100644 index 0000000..d07e1ff --- /dev/null +++ b/src/app/me/AboutMe.module.css @@ -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; +} diff --git a/src/app/me/AboutMe.tsx b/src/app/me/AboutMe.tsx new file mode 100644 index 0000000..5c44186 --- /dev/null +++ b/src/app/me/AboutMe.tsx @@ -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; + +const Input = React.forwardRef((props, ref) => { + // get console to shut up + const inputProps = { ...props }; + delete inputProps.displayImage; + + return ( +
+ + + {props.displayImage && ( + {"Your + )} + +
+ + + {props.error != null &&

{props.error}

} +
+
+ ); +}); +Input.displayName = "Input"; + +async function fileAsBase64(f: File) { + const reader = new FileReader(); + reader.readAsArrayBuffer(f); + return new Promise((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(null); + const emailRef = React.useRef(null); + const avatarRef = React.useRef(null); + const submitRef = React.useRef(null); + + const [avatar, setAvatar] = React.useState( + info.avatar ?? null + ); + + const currentPasswordRef = React.useRef(null); + const newPasswordRef = React.useRef(null); + const confirmPasswordRef = React.useRef(null); + const submitPasswordRef = React.useRef(null); + + const [incorrectPassword, setIncorrectPassword] = React.useState(false); + const [passwordMismatch, setPasswordMismatch] = React.useState(false); + const [avatarBig, setAvatarBig] = React.useState(false); + + return ( +
+

User information

+
{ + 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); + } + }} + > + + + + + {/* why, html gods, why? */} + + + { + 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} + /> + +
+ +
+
+ +
+ +

Change password

+
{ + 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); + } + }} + > + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index ec6879b..046c7e1 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -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

Not logged in

; + window.location.href = "/login"; + return; } - return

{user.username}

; + const info = await getUserInfo(user); + if (info === null) { + window.location.href = "/login"; + return; + } + + return ; } diff --git a/src/app/register/RegisterForm.module.css b/src/app/register/RegisterForm.module.css index ac2307c..dc23419 100644 --- a/src/app/register/RegisterForm.module.css +++ b/src/app/register/RegisterForm.module.css @@ -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); } diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx index 8f3dd3e..ea2d3cf 100644 --- a/src/app/register/RegisterForm.tsx +++ b/src/app/register/RegisterForm.tsx @@ -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(null); const displayNameRef = React.useRef(null); const emailRef = React.useRef(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, diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 4fc9901..85353b6 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -6,12 +6,13 @@ export default function Page() { const cookieStore = cookies(); const ticket = cookieStore.get("ticket"); if (ticket === null) { - return
Ticket is null?
; + window.location.href = "/"; + return; } return (
- +
); } diff --git a/src/auth.ts b/src/auth.ts index b92aa56..5941098 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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: { diff --git a/src/components/ColorChanger.tsx b/src/components/ColorChanger.tsx index 949dc94..33c6db3 100644 --- a/src/components/ColorChanger.tsx +++ b/src/components/ColorChanger.tsx @@ -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! }; diff --git a/src/ldap.ts b/src/ldap.ts index ce0fc98..8a4d187 100644 --- a/src/ldap.ts +++ b/src/ldap.ts @@ -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 + } + } + }); +}