Compare commits

..

1 Commits
main ... dex

Author SHA1 Message Date
Julian 41033d2b6f
Work with Dex in an iframe 2023-04-30 01:36:00 +00:00
45 changed files with 1808 additions and 1922 deletions

5
dex/robots.txt Normal file
View File

@ -0,0 +1,5 @@
User-agent: *
Disallow: /
User-agent: LUN-4
Allow: *

17
dex/static/main.css Normal file
View File

@ -0,0 +1,17 @@
body,
html {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
iframe {
width: 100vw;
height: 100vh;
border: none;
margin: 0;
padding: 0;
}

View File

@ -0,0 +1,74 @@
{{ template "header.html" . }}
<script>
let source = null;
function sendToGluestick(msg) {
source.postMessage(msg, {
targetOrigin: '{{ print (extra "gluestick_url") }}'
});
}
document.addEventListener("DOMContentLoaded", () => {
window.addEventListener(
"message",
(e) => {
if (e.origin != '{{ print (extra "gluestick_url") }}') return;
switch (e.data.type) {
case "appResult":
submitApproval(e.data.success);
break;
}
},
false
);
const iframe = document.querySelector("iframe");
iframe.addEventListener("load", () => {
source = iframe.contentWindow;
sendToGluestick({ type: "hello" });
sendToGluestick({
type: "appInfo",
client: "{{ .Client }}",
scopes:
{{ if .Scopes }}
[
{{ range $scope := .Scopes }}
"{{ $scope }}",
{{ end }}
]
{{ else }}
null
{{ end }}
});
});
});
async function submitApproval(doesApprove) {
const params = new URLSearchParams();
params.append("req", "{{ .AuthReqID }}");
params.append("approval", doesApprove ? "approve" : "rejected");
// cursed shit to work about cors
const form = document.createElement("form");
form.method = "POST";
const hiddenReq = document.createElement("input");
hiddenReq.type = "hidden";
hiddenReq.name = "req";
hiddenReq.value = "{{ .AuthReqID }}";
form.appendChild(hiddenReq);
const hiddenApproval = document.createElement("input");
hiddenApproval.type = "hidden";
hiddenApproval.name = "approval";
hiddenApproval.value = doesApprove ? "approve" : "rejected";
form.appendChild(hiddenApproval);
document.body.appendChild(form);
form.submit();
}
</script>
<iframe src='{{ print (extra "gluestick_url") "/dex/approval" }}'></iframe>
{{ template "footer.html" . }}

View File

@ -0,0 +1 @@
{{ template "header.html" . }} {{ template "footer.html" . }}

View File

@ -0,0 +1 @@
{{ template "header.html" . }} {{ template "footer.html" . }}

1
dex/templates/error.html Normal file
View File

@ -0,0 +1 @@
{{ template "header.html" . }} {{ template "footer.html" . }}

View File

@ -0,0 +1,2 @@
</body>
</html>

11
dex/templates/header.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>gluestick</title>
<link rel="icon" href='{{ print (extra "gluestick_url") "/icon.svg" }}' />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<link href='{{ url .ReqPath "static/main.css" }}' rel="stylesheet" />
</head>
<body>

1
dex/templates/login.html Normal file
View File

@ -0,0 +1 @@
{{ template "header.html" . }} {{ template "footer.html" . }}

1
dex/templates/oob.html Normal file
View File

@ -0,0 +1 @@
{{ template "header.html" . }} {{ template "footer.html" . }}

View File

@ -0,0 +1,61 @@
{{ template "header.html" . }}
<script>
let source = null;
function sendToGluestick(msg) {
source.postMessage(msg, {
targetOrigin: '{{ print (extra "gluestick_url") }}'
});
}
document.addEventListener("DOMContentLoaded", () => {
window.addEventListener(
"message",
(e) => {
if (e.origin != '{{ print (extra "gluestick_url") }}') return;
switch (e.data.type) {
case "passwordSubmit":
const { username, password } = e.data;
submitLogin(username, password);
break;
}
},
false
);
const iframe = document.querySelector("iframe");
iframe.addEventListener("load", () => {
source = iframe.contentWindow;
sendToGluestick({ type: "hello" });
});
});
async function submitLogin(username, password) {
const params = new URLSearchParams();
params.append("login", username);
params.append("password", password);
const req = await fetch("{{ .PostURL }}", {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: params,
method: "POST",
redirect: "follow"
});
if (req.ok && req.redirected) {
window.location.href = req.url;
return;
}
sendToGluestick({
type: "passwordSubmitResult",
success: req.ok
});
}
</script>
<iframe src='{{ print (extra "gluestick_url") "/dex/password" }}'></iframe>
{{ template "footer.html" . }}

2
environment.d.ts vendored
View File

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

View File

@ -29,7 +29,7 @@
pname = "gluestick";
version = "0.1.0";
src = ./.;
npmDepsHash = "sha256-JPsXIPyiGycT/4dcg78qAz+qqIRYpSR24NWeu+5jLk0=";
npmDepsHash = "sha256-keOreamXKunlJzU2AKJo0J02ZxQrjLdoCIMCaiwEU4Y=";
nativeBuildInputs = inputs;
buildInputs = inputs;

View File

@ -3,7 +3,18 @@ const nextConfig = {
experimental: {
appDir: true
},
output: "standalone"
output: "standalone",
// Allow Dex to use gluestick in an iframe
headers: async () => {
return [{
source: "/dex/(.*)",
headers: [{
key: "Content-Security-Policy",
value: `frame-ancestors 'self' ${process.env.DEX_DOMAIN}`
}]
}]
}
};
module.exports = nextConfig;

2538
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,29 +0,0 @@
"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 };
}

View File

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

View File

@ -1,26 +0,0 @@
"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

@ -0,0 +1,29 @@
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

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

View File

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

View File

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

View File

@ -1,41 +0,0 @@
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

@ -0,0 +1,10 @@
.buttons {
display: flex;
width: 100%;
justify-content: center;
gap: 1rem;
}
.approvalText {
padding: 1rem 0;
}

View File

@ -0,0 +1,59 @@
"use client";
import React from "react";
import { AppInfo, useDex } from "../dex";
import styles from "./DexApprovalForm.module.css";
export default function DexApprovalForm({ domain }: { domain: string }) {
const [appInfo, setAppInfo] = React.useState<AppInfo | null>(null);
const sendToDex = useDex(domain, (msg) => {
switch (msg.type) {
case "appInfo":
setAppInfo(msg);
break;
}
});
if (appInfo === null) return <></>;
// Stolen from LoginForm
return (
<div>
<h1>Sign into {appInfo.client}</h1>
<div className={styles.approvalText}>
{appInfo.scopes != null ? (
<>
<p>{appInfo.client} would like to:</p>
<ul>
{appInfo.scopes.map((scope) => (
<li key={scope}>{scope}</li>
))}
</ul>
</>
) : (
<p>{appInfo.client} doesn't have any special permissions.</p>
)}
</div>
<div className={styles.buttons}>
<input
type="submit"
value="Allow"
onClick={() => {
sendToDex({ type: "appResult", success: true });
}}
/>
<input
type="submit"
value="Deny"
onClick={() => {
sendToDex({ type: "appResult", success: false });
}}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import styles from "@/app/page.module.css";
import DexApprovalForm from "./DexApprovalForm";
export default async function Page() {
return (
<main className={styles.main}>
<DexApprovalForm domain={process.env.DEX_DOMAIN} />
</main>
);
}

41
src/app/dex/dex.tsx Normal file
View File

@ -0,0 +1,41 @@
import React from "react";
export type AppInfo = {
type: "appInfo";
client: string;
scopes: string[] | null;
};
export type Message =
| { type: "hello" }
| { type: "passwordSubmit"; username: string; password: string }
| { type: "passwordSubmitResult"; success: boolean }
| AppInfo
| { type: "appResult"; success: boolean };
export function useDex(domain: string, handler: (msg: Message) => void) {
const [source, setSource] = React.useState<MessageEventSource | null>(null);
const sendToDex = (msg: Message, maybeSource?: MessageEventSource) => {
let realSource = maybeSource ?? source;
realSource!.postMessage(msg, {
targetOrigin: domain
});
};
React.useEffect(() => {
window.addEventListener("message", (e) => {
if (e.origin !== domain) return;
const message: Message = e.data;
setSource(e.source);
if (message.type === "hello") {
sendToDex({ type: "hello" }, e.source!);
}
handler(message);
});
}, []);
return sendToDex;
}

View File

@ -0,0 +1,60 @@
"use client";
import React from "react";
import { useDex } from "../dex";
import { LoginFormValues, loginSchema } from "@/schemas";
import { Form, Formik } from "formik";
import PrettyForm from "@/components/PrettyForm";
import Input from "@/components/Input";
export default function DexPasswordForm({ domain }: { domain: string }) {
const [globalError, setGlobalError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const sendToDex = useDex(domain, (msg) => {
switch (msg.type) {
case "passwordSubmitResult":
setSubmitting(false);
if (!msg.success) setGlobalError("Invalid credentials.");
break;
}
});
async function handleFormSubmit({ username, password }: LoginFormValues) {
setSubmitting(true);
sendToDex({
type: "passwordSubmit",
username,
password
});
}
// Stolen from LoginForm
return (
<PrettyForm globalError={globalError}>
<Formik
initialValues={{ username: "", password: "" }}
onSubmit={handleFormSubmit}
validationSchema={loginSchema}
>
{() => (
<Form>
<Input
type="text"
placeholder="julian"
name="username"
label="Username"
/>
<Input
type="password"
placeholder="deeznuts47"
name="password"
label="Password"
/>
<input type="submit" value="Login" disabled={submitting} />
</Form>
)}
</Formik>
</PrettyForm>
);
}

View File

@ -0,0 +1,10 @@
import styles from "@/app/page.module.css";
import DexPasswordForm from "./DexPasswordForm";
export default async function Page() {
return (
<main className={styles.main}>
<DexPasswordForm domain={process.env.DEX_DOMAIN} />
</main>
);
}

View File

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

View File

@ -7,7 +7,6 @@
display: grid;
grid-template-columns: 300px 1fr;
column-gap: 2rem;
max-width: 100vw;
}
.profileTower *:first-child {
@ -72,19 +71,3 @@
width: 100%;
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

@ -7,6 +7,12 @@ import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger";
import Input, { Hint, Label } from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik";
import {
AboutMeFormValues,
PasswordUpdateFormValues,
aboutMeSchema,
passwordUpdateSchema
} from "@/schemas";
import PrettyForm from "@/components/PrettyForm";
import Toast from "@/components/Toast";
import { AuthProviderState } from "@/auth/AuthProvider";
@ -15,10 +21,11 @@ import DiscordIcon from "@/components/icons/DiscordIcon";
import GitHubIcon from "@/components/icons/GitHubIcon";
import TailscaleIcon from "@/components/icons/TailscaleIcon";
import MigaduIcon from "@/components/icons/MigaduIcon";
import { AboutMeSchema, aboutMeSchema } from "@/schemas";
import update from "@/actions/update";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { useRouter } from "next/navigation";
type UpdateResponse = {
ok: boolean;
error?: string;
};
export default function AboutMe({
info,
@ -31,9 +38,8 @@ export default function AboutMe({
const [globalError, setGlobalError] = React.useState<string | null>(null);
const [madeProfileChanges, setMadeChanges] = React.useState(false);
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
const router = useRouter();
const initialValues: AboutMeSchema = {
const initialValues: AboutMeFormValues = {
username: info.username,
displayName: info.displayName,
email: info.email,
@ -41,21 +47,78 @@ export default function AboutMe({
};
async function handleFormSubmit(
data: AboutMeSchema,
{ setSubmitting }: FormikHelpers<AboutMeSchema>
{ displayName, email, avatar }: AboutMeFormValues,
{ setSubmitting }: FormikHelpers<AboutMeFormValues>
) {
setMadeChanges(false);
setSubmitting(true);
const res = await update(data);
const req = await fetch("/api/update", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName,
email,
avatar: avatar != null ? avatar.split(",")[1] : null
})
});
setSubmitting(false);
if (res.ok) {
setMadeChanges(true);
} else {
if (res.error != undefined) {
setGlobalError("Unknown error: " + res.error);
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "avatarBig":
break;
}
}
setMadeChanges(true);
} catch {
console.error(req);
}
}
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 (
@ -64,7 +127,7 @@ export default function AboutMe({
<Formik
initialValues={initialValues}
onSubmit={handleFormSubmit}
validationSchema={toFormikValidationSchema(aboutMeSchema)}
validationSchema={aboutMeSchema}
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
@ -87,7 +150,7 @@ export default function AboutMe({
<div>
<h2 className={styles.userName}>{info.username}</h2>
<div className={styles.rightGrid}>
<div className={styles.profile}>
<div>
{madeProfileChanges ? (
<Toast>Saved your changes.</Toast>
) : null}
@ -122,14 +185,7 @@ export default function AboutMe({
/>
<div className={styles.multiButtons}>
<button
type="button"
onClick={() => {
router.push("/me/change-password");
}}
>
Change password
</button>
<button type="button">Change password</button>
<button
type="button"
@ -177,6 +233,69 @@ export default function AboutMe({
)}
</Formik>
</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>
);
}

View File

@ -1,76 +0,0 @@
"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

@ -1,11 +0,0 @@
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

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

View File

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

View File

@ -18,7 +18,8 @@
}
.connection > svg {
height: 1.5rem;
width: 2rem;
height: 2rem;
margin-left: auto;
}

View File

@ -5,7 +5,6 @@ import classnames from "classnames";
import CheckIcon from "./icons/CheckIcon";
import { type AuthProviderState } from "@/auth/AuthProvider";
import { useRouter } from "next/navigation";
import unlink from "@/actions/unlink";
export default function Connection({
service,
@ -24,21 +23,21 @@ export default function Connection({
const interval = React.useRef<NodeJS.Timeout | null>();
const execute = async () => {
const name = authState?.name;
const name = authState?.name.toLowerCase();
if (!authState?.connected) {
router.push(`/oauth/${name?.toLowerCase()}/login`);
router.push(`/oauth/${name}/login`);
} else {
await unlink(name);
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
router.refresh();
}
};
const down = (e: React.MouseEvent | React.TouchEvent) => {
const mouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
interval.current = setTimeout(execute, holdTime);
};
const up = (e: React.MouseEvent | React.TouchEvent) => {
const mouseUp = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (interval.current) clearTimeout(interval.current);
};
@ -52,10 +51,8 @@ export default function Connection({
!authState?.connected ? styles.disconnected : styles.progress
)}
disabled={unavailable}
onMouseDown={down}
onMouseUp={up}
onTouchStart={down}
onTouchEnd={up}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
>
<div className={styles.iconContainer}>
{icon ? icon() : <span className={styles.dot}></span>}

View File

@ -74,6 +74,7 @@ export default function Input<T>(
title={props.title}
{...inputFields}
onChange={(event) => {
console.log(event);
if (props.customOnChange) {
console.log("using custom on change");
props.customOnChange(event, form);

View File

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

View File

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

View File

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

View File

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