Compare commits

..

No commits in common. "main" and "tic80" have entirely different histories.
main ... tic80

68 changed files with 1668 additions and 5202 deletions

3
.gitignore vendored
View File

@ -38,6 +38,3 @@ database.db*
# graphql-codegen
src/__generated__
# NixOS
result

View File

@ -50,7 +50,6 @@ After cloning, create an `.env.local` with the following contents (in `key=value
- `LDAP_BIND_PASSWORD`: the password of the bind user
- `BASE_DOMAIN`: the domain gluestick is deployed on, with a protocol and trailing slash
- This domain will be used for OAuth redirects - if you are testing locally, set it to `http://localhost:3000/`
- `DATABASE_URL`: a Prisma-like path to your database
Example config:
@ -70,7 +69,6 @@ GITHUB_TOKEN=redacted
GITHUB_ORG=n2pm
BASE_DOMAIN=https://gluestick.n2.pm/
DATABASE_URL=file:./database.db
```
### Generating code
@ -95,7 +93,7 @@ export GRAPHQL_CODEDGEN_AUTH=...
Then, generate the GraphQL and database code:
```shell
GRAPHQL_USE_INTROSPECTION=true npm run graphql-codegen
npm run graphql-codegen
npm run prisma-generate
```
@ -110,36 +108,7 @@ npm run start
## Developing
### Generating GraphQL code
Because the LLDAP GraphQL API is locked behind authentication, and of a quirk with `graphl-codegen` configuration files, we need to set a temporary environment variable to generate GraphQL code. If not using introspection, you will need a running LLDAP server.
Run the `get-token.js` helper script and set the environment variable from its output:
```shell
node get-token.js
export GRAPHQL_CODEDGEN_AUTH=...
```
Then, generate the GraphQL code:
```shell
npm run graphql-codegen
```
If you want to use introspection, set `GRAPHQL_USE_INTROSPECTION=true` before generating the code. You won't need to set the auth environment variable in this case.
### Working with Prisma
gluestick uses [Prisma](https://www.prisma.io/) for accessing the database. If you will be modifying the database schema, you will need to work with it. Consider taking some time to familiarize yourself with the [Prisma CLI](https://www.prisma.io/docs/reference/api-reference/command-reference) first.
When first cloning, generate the Prisma client:
```shell
npm run prisma-generate
```
### Running the server
You'll want to run these two commands at the same time:
```shell
# Next.js hot reload
@ -150,3 +119,5 @@ npm run dev | pino-pretty
# Only required if working on GraphQL code
npm run graphql-codegen -- -w
```
If you're interacting with the database, take some time to familiarize yourself with the [Prisma CLI](https://www.prisma.io/docs/reference/api-reference/command-reference).

View File

@ -4,14 +4,8 @@ import { CodegenConfig } from "@graphql-codegen/cli";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
const useIntrospection = ["1", "true"].includes(
process.env.GRAPHQL_USE_INTROSPECTION?.toLowerCase() ?? ""
);
const config: CodegenConfig = {
schema: useIntrospection
? "introspection.json"
: {
schema: {
[`http://${process.env.LDAP_HOST}:17170/api/graphql`]: {
headers: {
// can't make the request automatically (await on top level)
@ -24,12 +18,10 @@ const config: CodegenConfig = {
generates: {
"./src/__generated__/": {
preset: "client",
plugins: [],
presetConfig: {
gqlTagName: "gql"
}
},
"introspection.json": {
plugins: ["introspection"]
}
},
ignoreNoDocuments: true

3
environment.d.ts vendored
View File

@ -2,13 +2,11 @@ import { PrismaClient } from "@prisma/client";
import { Client as LDAPClient } from "ldapts";
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { LLDAPAuthResponse } from "@/ldap";
import { Logger } from "pino";
declare global {
var prisma: PrismaClient | undefined;
var ldapClient: LDAPClient | undefined;
var authResponse: LLDAPAuthResponse | undefined;
var logger: Logger;
namespace NodeJS {
interface ProcessEnv {
@ -27,7 +25,6 @@ declare global {
GITHUB_ORG: string;
BASE_DOMAIN: string;
API_TOKEN?: string;
}
}
}

View File

@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1682656005,
"narHash": "sha256-fYplYo7so1O+rSQ2/aS+SbTPwLTeoUXk4ekKNtSl4P8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6806b63e824f84b0f0e60b6d660d4ae753de0477",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

182
flake.nix
View File

@ -1,182 +0,0 @@
{
description =
"NotNet's one stop shop for authentication and account onboarding";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
packages = flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages."${system}";
# I really cannot be assed to pick this apart
inputs = with pkgs; [ vips pkg-config python3 ];
# https://github.com/prisma/prisma/issues/3026#issuecomment-927258138
prismaHook = with pkgs; ''
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
'';
in rec {
packages.gluestick = pkgs.buildNpmPackage {
pname = "gluestick";
version = "0.1.0";
src = ./.;
npmDepsHash = "sha256-JPsXIPyiGycT/4dcg78qAz+qqIRYpSR24NWeu+5jLk0=";
nativeBuildInputs = inputs;
buildInputs = inputs;
preBuild = ''
${prismaHook}
# Use the introspection.json, because we can't connect to the API at build time
GRAPHQL_USE_INTROSPECTION=true npm run graphql-codegen
npm run prisma-generate
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r .next/standalone $out/server
cp -r .next/static $out/server/.next/static
cp -r public $out/server/public
cp -r prisma $out/prisma
mkdir -p $out/bin
cat > $out/bin/gluestick <<EOF
#!${pkgs.stdenv.shell}
${prismaHook}
${pkgs.nodejs}/bin/node $out/server/server.js \$@
EOF
chmod +x $out/bin/gluestick
cat > $out/bin/prisma <<EOF
#!${pkgs.stdenv.shell}
${prismaHook}
${pkgs.nodePackages.prisma}/bin/prisma \$@
EOF
chmod +x $out/bin/prisma
runHook postInstall
'';
meta = with pkgs.lib; {
description =
"NotNet's one stop shop for authentication and account onboarding";
homepage = "https://git.n2.pm/NotNet/gluestick";
license = licenses.mit;
};
};
apps.gluestick = flake-utils.lib.mkApp {
name = "gluestick";
drv = packages.gluestick;
};
devShell = pkgs.mkShell {
inputsFrom = [ packages.gluestick ];
shellHook = ''
${prismaHook}
if [ -f .env.local ]; then
set -a
source .env.local
set +a
fi
'';
};
});
in packages // {
nixosModule = { config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.gluestick;
pkg = self.packages.${pkgs.system}.gluestick;
in {
options.services.gluestick = {
enable = mkEnableOption "gluestick";
user = mkOption {
type = types.str;
default = "gluestick";
};
group = mkOption {
type = types.str;
default = "gluestick";
};
port = mkOption {
type = types.int;
default = 3000;
};
envFile = mkOption {
type = types.path;
default = "/var/lib/gluestick/.env.local";
};
databaseFile = mkOption {
type = types.path;
default = "/var/lib/gluestick/database.db";
};
};
config = mkIf cfg.enable {
systemd.services.gluestick = {
description = "gluestick";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
preStart = ''
export DATABASE_URL="file:${cfg.databaseFile}"
${pkg}/bin/prisma migrate deploy --schema=${pkg}/prisma/schema.prisma
'';
script = ''
export PORT=${toString cfg.port}
export NODE_ENV=production
export DATABASE_URL="file:${cfg.databaseFile}"
set -a
source ${cfg.envFile}
set +a
${pkg}/bin/gluestick
'';
serviceConfig = {
User = cfg.user;
Group = cfg.group;
Restart = "always";
WorkingDirectory = "/var/lib/gluestick";
};
};
users = {
users = mkIf (cfg.user == "gluestick") {
gluestick = {
home = "/var/lib/gluestick";
createHome = true;
group = cfg.group;
isSystemUser = true;
};
};
groups = mkIf (cfg.group == "gluestick") { gluestick = { }; };
};
};
};
};
}

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,7 @@
const nextConfig = {
experimental: {
appDir: true
},
output: "standalone"
}
};
module.exports = nextConfig;

2564
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,20 +25,18 @@
"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",
"@graphql-codegen/client-preset": "^3.0.1",
"@graphql-codegen/introspection": "^3.0.1",
"@types/uuid": "^9.0.1",
"pino-pretty": "^10.0.0",
"prisma": "^4.13.0"

View File

@ -4,7 +4,7 @@ generator client {
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
url = "file:./database.db"
}
model AuthTicket {

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 {
if (avatarBuf.length > 1_000_000) {
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

@ -1,53 +0,0 @@
@font-face {
font-family: lunchtype;
font-weight: 400;
src: url("/fonts/lunchtype22-regular.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-style: italic;
src: url("/fonts/lunchtype22-regular-italic.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
src: url("/fonts/lunchtype22-medium.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-style: italic;
src: url("/fonts/lunchtype22-medium.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-stretch: condensed;
src: url("/fonts/lunchtype25-regular-condensed.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-stretch: condensed;
src: url("/fonts/lunchtype25-medium-condensed.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-stretch: expanded;
src: url("/fonts/lunchtype24-regular-expanded.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-stretch: expanded;
src: url("/fonts/lunchtype24-medium-expanded.woff2") format("woff2");
}

View File

@ -48,16 +48,6 @@
:root {
--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;
}
* {
@ -72,46 +62,27 @@ body {
overflow-x: hidden;
color: var(--fg);
background-color: var(--bg);
font-family: lunchtype, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
sans-serif;
}
h2 {
font-size: 2rem;
font-stretch: expanded;
font-weight: 500;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
html,
body,
input,
button,
label {
transition: background-color var(--theme-transition),
color var(--theme-transition);
}
input:disabled, button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
input,
button {
font: inherit;
color: inherit;
background-color: inherit;
}
button,
input[type="submit"] {
padding: 0.5em 1em;
background-color: var(--bg-dark);
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
input:disabled,
button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
input::placeholder {
@ -123,8 +94,3 @@ a {
color: inherit;
text-decoration: none;
}
::selection {
background: var(--fg);
color: var(--bg);
}

View File

@ -1,6 +1,5 @@
import ColorChanger from "@/components/ColorChanger";
import "./globals.css";
import "./fonts.css";
export const metadata = {
title: "gluestick",

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

@ -1,37 +1,28 @@
.content {
width: min-content;
margin: 2rem auto;
max-width: 700px;
margin: 0 auto;
}
.profileGrid {
display: grid;
grid-template-columns: 300px 1fr;
column-gap: 2rem;
max-width: 100vw;
/* todo */
}
.profileTower *:first-child {
margin-top: 0 !important;
.divider {
width: 400px;
background-color: var(--fg-darker);
height: 1px;
border: none;
margin: 1rem auto;
}
.connections {
margin-top: 1rem;
}
.connections > *:nth-child(2) {
margin-top: 0.5rem;
}
.rightGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
column-gap: 2rem;
}
.userName {
font-size: 3rem;
text-transform: uppercase;
margin: 0;
.logout {
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
}
/* stolen from prettyform */
@ -41,8 +32,6 @@
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
width: 100%;
}
.authProviderList {
@ -59,32 +48,25 @@
height: 100%;
}
.multiButtons {
margin: 1rem 0;
white-space: nowrap;
display: flex;
justify-content: space-between;
}
/* 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;
.spacer {
border: 0;
border-bottom: 1px solid var(--fg-darker);
width: 100%;
margin: 1rem 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
}
/* 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;
}
/* when clicked */
.progress:active {
transition: all 3s linear !important;
background-position: left bottom !important;
}

View File

@ -5,35 +5,100 @@ import { UserInfo } from "@/ldap";
import React from "react";
import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger";
import Input, { Hint, Label } from "@/components/Input";
import Input 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";
import Connection from "@/components/Connection";
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";
import { exec } from "child_process";
type UpdateResponse = {
ok: boolean;
error?: string;
};
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);
});
}
function AuthProviderButton({ provider }: { provider: AuthProviderState }) {
// bullshit hack
const holdTime = provider.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
const inputRef = React.useRef<HTMLInputElement>(null);
const execute = async () => {
const name = provider.name.toLowerCase();
if (!provider.connected) {
window.location.href = `/oauth/${name}/login`;
} else {
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
window.location.reload();
}
};
const mouseDown = () => {
interval.current = setTimeout(execute, holdTime);
};
const mouseUp = () => {
if (interval.current) clearTimeout(interval.current);
};
return (
<input
type="submit"
className={
styles.fancyInput + " " + (provider.connected ? styles.progress : "")
}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
value={provider.connected ? "Disconnect" : "Connect"}
ref={inputRef}
/>
);
}
function AuthProviderEntry({ provider }: { provider: AuthProviderState }) {
return (
<>
<p>
{provider.name}:{" "}
{provider.connected ? provider.username : "Not connected"}
</p>
<AuthProviderButton provider={provider} />
</>
);
}
export default function AboutMe({
info,
providers: [discordState, githubState]
providers
}: {
info: UserInfo;
providers: AuthProviderState[];
}) {
// TODO: Reimplement password changing.
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,63 +106,99 @@ 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 (
<div className={styles.content}>
<h2 className={styles.userName}>{info.username}</h2>
<PrettyForm globalError={globalError}>
<Formik
initialValues={initialValues}
onSubmit={handleFormSubmit}
validationSchema={toFormikValidationSchema(aboutMeSchema)}
validationSchema={aboutMeSchema}
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
<div className={styles.profileTower}>
<Input
type="file"
name="avatar"
customRender={(fieldProps) => (
<AvatarChanger
currentAvatarBlob={fieldProps.field.value}
onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
vertical
/>
)}
/>
</div>
<div>
<h2 className={styles.userName}>{info.username}</h2>
<div className={styles.rightGrid}>
<div className={styles.profile}>
{madeProfileChanges ? (
<Toast>Saved your changes.</Toast>
) : null}
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null}
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
hint="This can&rsquo;t be changed."
title="You can't change your username."
/>
<Input
type="text"
@ -112,7 +213,20 @@ export default function AboutMe({
defaultValue={info.email}
/>
<hr className={styles.spacer} />
<Input
type="file"
name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
customRender={(fieldProps) => (
<AvatarChanger
currentAvatarBlob={fieldProps.field.value}
onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
/>
)}
/>
<input
type="submit"
@ -120,63 +234,77 @@ export default function AboutMe({
className={styles.fancyInput}
disabled={isSubmitting}
/>
<div className={styles.multiButtons}>
<button
type="button"
onClick={() => {
router.push("/me/change-password");
}}
>
Change password
</button>
<button
type="button"
onClick={async () => {
document.cookie =
"ticket=; expires=" +
new Date().toUTCString() +
"; path=/";
window.location.href = "/";
}}
>
Log out
</button>
</div>
</div>
<div className={styles.connections}>
<Label>Connections</Label>
<Hint>Click to link, hold to unlink.</Hint>
<Connection
service="Discord"
authState={discordState}
icon={DiscordIcon}
/>
<Connection
service="GitHub"
authState={githubState}
icon={GitHubIcon}
/>
<Connection
service="Tailscale"
icon={TailscaleIcon}
unavailable
/>
<Connection
service="Migadu"
icon={MigaduIcon}
unavailable
/>
</div>
</div>
</div>
</Form>
)}
</Formik>
</PrettyForm>
<hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2>
<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>
<hr className={styles.divider} />
<h2 className={styles.header}>Connections</h2>
<div className={styles.authProviderList}>
{providers.map((provider) => (
<AuthProviderEntry provider={provider} key={provider.name} />
))}
</div>
<hr className={styles.divider} />
<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

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

View File

@ -21,6 +21,11 @@ export async function GET(request: Request) {
const id = await provider.getId();
const permitted = await provider.isPermitted();
if (!permitted) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
// If someone clicked register on the front page with an existing account,
// wire it to their user via the auth ticket
let gluestickId = null;
@ -30,11 +35,6 @@ export async function GET(request: Request) {
gluestickId = currentUser!.id;
}
if (!permitted && gluestickId == null) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
const userId = await DiscordAuthProvider.update(
id,
tokenBody.access_token,

View File

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

View File

@ -1,5 +1,5 @@
import { getLogger } from "@/logger";
import { GitHubAuthProvider, inviteToGitHub } from "@/auth/github";
import { GitHubAuthProvider } from "@/auth/github";
import {
AuthState,
authTicketOAuth,
@ -21,6 +21,11 @@ export async function GET(request: Request) {
const id = await provider.getId();
const permitted = await provider.isPermitted();
if (!permitted) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
// If someone clicked register on the front page with an existing account,
// wire it to their user via the auth ticket
let gluestickId = null;
@ -30,11 +35,6 @@ export async function GET(request: Request) {
gluestickId = currentUser!.id;
}
if (!permitted && gluestickId == null) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
const userId = await GitHubAuthProvider.update(
id,
tokenBody.access_token,
@ -42,7 +42,6 @@ export async function GET(request: Request) {
);
if (gluestickId != null) {
await inviteToGitHub(provider);
return new Response(null, {
status: 302,
headers: {
@ -53,16 +52,6 @@ export async function GET(request: Request) {
const [user, authTicket] = await authTicketOAuth(userId);
if (user?.username !== null) {
return new Response(null, {
status: 302,
headers: {
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
Location: "/me"
}
});
}
const username = await provider.getDisplayName();
const email = await provider.getEmail();
const avatarUrl = await provider.getAvatar();

View File

@ -3,36 +3,26 @@
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
}
.content {
margin-top: 2rem;
.form {
display: flex;
flex-direction: column;
font-stretch: expanded;
line-height: 1.5;
}
.content p {
text-align: center;
}
.icons {
.form div {
display: flex;
flex-direction: row;
align-items: center;
padding: 1rem;
justify-content: space-between;
}
.icons svg {
width: 4rem;
height: 4rem;
padding: 0.5rem;
.form div label {
margin-right: 1rem;
}
.icons svg:hover {
background-color: var(--bg-dark);
border-radius: 0.25rem;
.form div input {
width: 15rem;
}

View File

@ -1,31 +1,21 @@
import Logo from "@/components/Logo";
import styles from "./page.module.css";
import NotNetIcon from "@/components/icons/NotNetIcon";
import GitHubIcon from "@/components/icons/GitHubIcon";
import DiscordIcon from "@/components/icons/DiscordIcon";
import Image from "next/image";
export default function Home() {
return (
<main className={styles.main}>
<Logo />
<Image src="/icon.svg" alt="gluestick logo" width="256" height="256" />
<div className={styles.content}>
<p>login with</p>
<div className={styles.icons}>
<a href="/login" title="NotNet">
<NotNetIcon />
</a>
<a href="/oauth/discord/login" title="Discord">
<DiscordIcon />
</a>
<a href="/oauth/github/login" title="GitHub">
<GitHubIcon />
</a>
</div>
</div>
<p
style={{
display: "flex",
flexDirection: "column"
}}
>
<a href="/login">login</a>
<a href="/oauth/discord/login">register (discord)</a>
<a href="/oauth/github/login">register (github)</a>
</p>
</main>
);
}

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 }) => (
@ -122,7 +141,7 @@ export default function RegisterForm({
(avatarSource != null
? `We found your avatar from ${avatarSource}, but you can change it if you'd like.`
: "") +
" This will automatically be used as your avatar with supported services - maximum 2 MB. "
" This will automatically be used as your avatar with supported services - maximum 1 MB. "
}
type="file"
name="avatar"
@ -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

@ -20,6 +20,10 @@ function avatarUrlSource(url: URL): ValidAuthProvider | null {
return null;
}
function avatarUrlAllowed(url: URL): boolean {
return avatarUrlSource(url) !== null;
}
export default async function Page({
searchParams
}: {
@ -51,7 +55,7 @@ export default async function Page({
const blob = await req.blob();
const arrayBuffer = await blob.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length <= 2_000_000) {
if (buffer.length <= 1_000_000) {
// I hope you are doing well, you deserve the best of luck while working on this project -Ari
try {
const jpg = await ensureJpg(buffer);

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

@ -68,7 +68,7 @@ export class DiscordAuthProvider extends AuthProvider {
async getAvatar(): Promise<string | null> {
const me = await this.getMe();
return me.avatar !== null
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=1024`
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png`
: null;
}

View File

@ -14,18 +14,6 @@ type GitHubUserResponse = {
email: string;
};
async function getMembers(): Promise<GitHubUserResponse[]> {
const req = await fetch(
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`,
{
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
}
);
return await req.json();
}
export class GitHubAuthProvider extends AuthProvider {
private async getMe(): Promise<GitHubUserResponse> {
const req = await fetch("https://api.github.com/user", {
@ -39,8 +27,16 @@ export class GitHubAuthProvider extends AuthProvider {
async isPermitted(): Promise<boolean> {
const me = await this.getMe();
const members = await getMembers();
return members.some((user) => user.login === me.login);
const req = await fetch(
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`,
{
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
}
);
const res: GitHubUserResponse[] = await req.json();
return res.some((user) => user.login === me.login);
}
async getDisplayName(): Promise<string> {
@ -143,24 +139,3 @@ export class GitHubAuthProvider extends AuthProvider {
return a.userId;
}
}
export async function inviteToGitHub(auth: GitHubAuthProvider) {
const id = await auth.getId();
const members = await getMembers();
if (members.find((x) => x.id === parseInt(id))) return;
await fetch(
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/invitations`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json"
},
body: JSON.stringify({
invitee_id: parseInt(id),
role: "direct_member"
})
}
);
}

View File

@ -2,44 +2,32 @@
display: flex;
flex-flow: row nowrap;
gap: 1rem;
margin: 0.5rem 0;
}
.vertical {
flex-direction: column;
}
.avatarChanger .currentAvatar,
.avatarChanger svg {
.avatarChanger :is(img, svg) {
width: 3em;
height: 3em;
border-radius: 0.25rem;
}
.vertical.vertical .currentAvatar {
display: block;
width: 100%;
height: inherit;
aspect-ratio: 1/1;
}
.avatarChanger button svg {
width: 1.2em;
height: 1.2em;
margin-right: 0.5em;
}
.avatarChanger input[type="file"] {
.avatarChanger input[type=file] {
display: none;
}
.uploadButton {
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
padding: 0.5em 1em;
padding: 0.25em 1em;
cursor: pointer;
}

View File

@ -1,4 +1,3 @@
/* eslint-disable @next/next/no-img-element */
import React, { ChangeEvent } from "react";
import classnames from "classnames";
@ -9,12 +8,10 @@ import UserIcon from "./icons/UserIcon";
export default function AvatarChanger({
currentAvatarBlob,
onChange,
vertical = false
onChange
}: {
currentAvatarBlob: string | null;
onChange: (newAvatar: string) => void;
vertical?: boolean;
}) {
const input = React.useRef<HTMLInputElement>(null);
@ -28,20 +25,11 @@ export default function AvatarChanger({
// I give you the most support and well wishes while you work on this project -Ari
return (
<div
className={classnames(
styles.avatarChanger,
vertical ? styles.vertical : null
)}
>
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
{currentAvatarBlob != null ? (
<img
className={styles.currentAvatar}
src={currentAvatarBlob!}
alt="Your avatar"
/>
<img src={currentAvatarBlob!} alt="Your avatar" />
) : (
<UserIcon className={styles.currentAvatar} />
<UserIcon />
)}
<button

View File

@ -1,12 +1,14 @@
.colorChanger {
position: fixed;
right: 10px;
bottom: 10px;
transition: filter 100ms ease-in-out;
filter: grayscale(100%) opacity(20%);
cursor: pointer;
right: 0;
bottom: 0;
padding: 8px;
opacity: 30%;
transition: opacity 0.25s ease-in-out;
}
.colorChanger:hover {
filter: none;
opacity: 100%;
}

View File

@ -179,8 +179,8 @@ export default function ColorChanger() {
return (
<Image
src="/paint.svg"
width="32"
height="32"
width="64"
height="64"
alt="paint"
title={current}
onClick={() => {

View File

@ -1,80 +0,0 @@
.connection {
padding: 1rem 2rem;
background-color: var(--bg-dark);
margin: 1rem 0;
border-radius: 0.25rem;
width: 300px;
display: flex;
flex-flow: row nowrap;
column-gap: 1rem;
align-items: center;
text-align: left;
}
.connection .iconContainer > svg {
width: 3rem;
height: 3rem;
margin-left: auto;
}
.connection > svg {
height: 1.5rem;
margin-left: auto;
}
.connection .dot {
width: 1rem;
height: 1rem;
margin: 1rem;
background-color: var(--fg);
border-radius: 50%;
}
.iconContainer {
font-size: 2.5em;
margin-right: 0.75rem;
display: flex;
align-items: center;
}
.info {
display: flex;
flex-flow: column nowrap;
row-gap: 0.25rem;
}
.serviceName {
font-weight: 500;
font-stretch: expanded;
font-size: 1.3em;
}
.linkedIdentity {
opacity: 0.7;
}
.unavailable.unavailable {
background-color: var(--bg-darker);
color: var(--fg-dark);
opacity: 0.5;
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

@ -1,79 +0,0 @@
import React from "react";
import styles from "./Connection.module.css";
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,
unavailable = false,
authState,
icon
}: {
service: string;
unavailable?: boolean;
authState?: AuthProviderState;
icon?: () => JSX.Element;
}) {
const router = useRouter();
const holdTime = authState?.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
const execute = async () => {
const name = authState?.name;
if (!authState?.connected) {
router.push(`/oauth/${name?.toLowerCase()}/login`);
} else {
await unlink(name);
router.refresh();
}
};
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 (
<button
type="button"
className={classnames(
styles.connection,
unavailable ? styles.unavailable : null,
!authState?.connected ? styles.disconnected : styles.progress
)}
disabled={unavailable}
onMouseDown={down}
onMouseUp={up}
onTouchStart={down}
onTouchEnd={up}
>
<div className={styles.iconContainer}>
{icon ? icon() : <span className={styles.dot}></span>}
</div>
<div className={styles.info}>
<div className={styles.serviceName}>{service}</div>
{authState?.connected !== false ? (
<div
className={styles.linkedIdentity}
title={
authState?.id ?? "This integration is currently unavailable."
}
>
{unavailable ? "Unavailable" : authState!.username}
</div>
) : null}
</div>
{authState?.connected && !unavailable ? <CheckIcon /> : null}
</button>
);
}

View File

@ -13,7 +13,7 @@
margin: 1rem 0;
}
.label {
.formRow label {
display: block;
font-variant: all-small-caps;
font-size: 105%;

View File

@ -1,7 +1,6 @@
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
import React, { LabelHTMLAttributes } from "react";
import React from "react";
import styles from "./Input.module.css";
import classnames from "classnames";
type CustomInputProps<T> = {
customRender?: (fieldProps: FieldProps) => React.ReactNode;
@ -12,37 +11,15 @@ type CustomInputProps<T> = {
) => void;
};
export function Label({
children,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label className={classnames(styles.label, props.className)} {...props}>
{children}
</label>
);
}
export function Hint({
children,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label className={classnames(styles.hint, props.className)} {...props}>
{children}
</label>
);
}
export default function Input<T>(
props: CustomInputProps<T> &
FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }>
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }>
) {
const generatedId = React.useId();
return (
<div className={classnames("form-row", styles.formRow)}>
{props.label ? <Label htmlFor={generatedId}>{props.label}</Label> : null}
<div className={styles.formRow}>
<label htmlFor={generatedId}>{props.label}</label>
<Field id={generatedId} {...props}>
{(fieldProps: FieldProps) => {
let { field, meta, form } = fieldProps;
@ -74,6 +51,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 +0,0 @@
.logo {
max-width: 700px;
}

View File

@ -1,115 +0,0 @@
import React from "react";
import styles from "./Logo.module.css";
export default function Logo() {
return (
<svg
viewBox="0 0 385 100"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
className={styles.logo}
>
<defs>
<linearGradient
x1="0%"
y1="50%"
x2="99.3908435%"
y2="50%"
id="linearGradient-neowcgjypg-1"
>
<stop stopColor="currentColor" offset="0%"></stop>
<stop
stopColor="currentColor"
stopOpacity="0.15"
offset="100%"
></stop>
</linearGradient>
<path
d="M3.8458278,-5.15911009e-17 L13.2928824,-1.94256078e-16 C14.6301605,-7.98342804e-16 15.1150899,0.139238417 15.6039788,0.400699056 C16.0928677,0.662159695 16.4765505,1.04584256 16.7380112,1.53473144 C16.9994718,2.02362033 17.1387102,2.50854969 17.1387102,3.8458278 L17.1387102,50.4723403 C17.1387102,51.8096184 16.9994718,52.2945477 16.7380112,52.7834366 C16.4765505,53.2723255 16.0928677,53.6560084 15.6039788,53.917469 C15.1150899,54.1789296 14.6301605,54.3181681 13.2928824,54.3181681 L3.8458278,54.3181681 C2.50854969,54.3181681 2.02362033,54.1789296 1.53473144,53.917469 C1.04584256,53.6560084 0.662159695,53.2723255 0.400699056,52.7834366 C0.139238417,52.2945477 6.81641737e-16,51.8096184 -2.80991422e-15,50.4723403 L-2.71214089e-16,3.8458278 C-7.93416146e-16,2.50854969 0.139238417,2.02362033 0.400699056,1.53473144 C0.662159695,1.04584256 1.04584256,0.662159695 1.53473144,0.400699056 C2.02362033,0.139238417 2.50854969,-8.81235264e-16 3.8458278,-5.15911009e-17 Z"
id="path-neowcgjypg-2"
></path>
<rect
id="path-neowcgjypg-4"
x="7.20209614e-15"
y="-4.94132864e-15"
width="26.0091852"
height="21.4354612"
></rect>
</defs>
<g id="Logo" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g
id="Logos/Ari-Inspired-Dark"
transform="translate(-73.669529, -60.500000)"
>
<g id="Group" transform="translate(73.669529, 60.500000)">
<path
d="M27.072843,77.2472777 C33.3387658,83.1496155 40.9633072,86.1007845 49.9464672,86.1007845 C63.4212073,86.1007845 141.296207,74.7480477 207.142001,74.7480477 C251.039197,74.7480477 306.545511,77.2640648 373.660942,82.296099"
id="glue"
stroke="url(#linearGradient-neowcgjypg-1)"
strokeWidth="15"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<text
id="text"
fontFamily="lunchtype"
fontSize="75.8217291"
fontStretch="expanded"
fontWeight="500"
letterSpacing="-3.37037037"
fill="currentColor"
>
<tspan x="45.3058029" y="72">
gluestick
</tspan>
</text>
<g
id="gluestick"
transform="translate(19.029903, 46.901517) rotate(-10.000000) translate(-19.029903, -46.901517) translate(6.025311, 11.065381)"
>
<path
d="M21.6562264,60.1722714 L20.1948974,69.3055775 C20.1062227,69.8597942 20.0511475,70.0678092 19.91889,70.2630516 C19.367519,70.6507522 19.1534152,70.6722714 18.5921494,70.6722714 L8.61779953,70.6722714 C8.05653367,70.6722714 7.84242989,70.6507522 7.62874419,70.5510023 C7.15880144,70.0678092 7.10372619,69.8597942 7.01505152,69.3055775 L5.55372254,60.1722714 L21.6562264,60.1722714 Z"
id="glue"
stroke="currentColor"
strokeWidth="2"
fill="currentColor"
></path>
<g id="Rectangle-2" transform="translate(5.000000, 6.000000)">
<mask id="mask-neowcgjypg-3" fill="white">
<use xlinkHref="#path-neowcgjypg-2"></use>
</mask>
<path
stroke="currentColor"
strokeWidth="2"
d="M13.2928824,-1 C14.9133577,-1 15.4869268,-0.795928047 16.0755776,-0.481114142 C16.7413126,-0.125075161 17.2637854,0.397397573 17.6198244,1.06313259 C17.9346383,1.65178341 18.1387102,2.22535247 18.1387102,3.8458278 L18.1387102,50.4723403 C18.1387102,52.0928156 17.9346383,52.6663846 17.6198244,53.2550355 C17.2637854,53.9207705 16.7413126,54.4432432 16.0755776,54.7992822 C15.4869268,55.1140961 14.9133577,55.3181681 13.2928824,55.3181681 L3.8458278,55.3181681 C2.22535247,55.3181681 1.65178341,55.1140961 1.06313259,54.7992822 C0.397397573,54.4432432 -0.125075161,53.9207705 -0.481114142,53.2550355 C-0.795928047,52.6663846 -1,52.0928156 -1,50.4723403 L-1,3.8458278 C-1,2.22535247 -0.795928047,1.65178341 -0.481114142,1.06313259 C-0.125075161,0.397397573 0.397397573,-0.125075161 1.06313259,-0.481114142 C1.65178341,-0.795928047 2.22535247,-1 3.8458278,-1 Z"
></path>
</g>
<g
id="orange-border-stick-2"
transform="translate(-0.000000, -0.000000)"
>
<mask id="mask-neowcgjypg-5" fill="white">
<use xlinkHref="#path-neowcgjypg-4"></use>
</mask>
<g id="Rectangle"></g>
<path
d="M8.8458278,6 L18.2928824,6 C19.6301605,6 20.1150899,6.13923842 20.6039788,6.40069906 C21.0928677,6.66215969 21.4765505,7.04584256 21.7380112,7.53473144 C21.9994718,8.02362033 22.1387102,8.50854969 22.1387102,9.8458278 L22.1387102,56.4723403 C22.1387102,57.8096184 21.9994718,58.2945477 21.7380112,58.7834366 C21.4765505,59.2723255 21.0928677,59.6560084 20.6039788,59.917469 C20.1150899,60.1789296 19.6301605,60.3181681 18.2928824,60.3181681 L8.8458278,60.3181681 C7.50854969,60.3181681 7.02362033,60.1789296 6.53473144,59.917469 C6.04584256,59.6560084 5.66215969,59.2723255 5.40069906,58.7834366 C5.13923842,58.2945477 5,57.8096184 5,56.4723403 L5,9.8458278 C5,8.50854969 5.13923842,8.02362033 5.40069906,7.53473144 C5.66215969,7.04584256 6.04584256,6.66215969 6.53473144,6.40069906 C7.02362033,6.13923842 7.50854969,6 8.8458278,6 Z"
id="orange-border-stick"
fill="currentColor"
mask="url(#mask-neowcgjypg-5)"
></path>
</g>
<path
d="M8,6 L19.1387102,6 C20.7955645,6 22.1387102,7.34314575 22.1387102,9 L22.1387102,21.444419 L22.1387102,21.444419 L5,21.444419 L5,9 C5,7.34314575 6.34314575,6 8,6 Z"
id="orange-portion"
fill="currentColor"
></path>
</g>
</g>
</g>
</g>
</svg>
);
}

View File

@ -1,3 +1,7 @@
.form {
max-width: 500px;
}
.form :is(button, input)[type="submit"] {
background: var(--bg-dark);
border: 0;

View File

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

View File

@ -6,8 +6,8 @@ export default function CheckIcon() {
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="check" fill="currentColor" fillRule="nonzero">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="check" fill="currentColor" fill-rule="nonzero">
<path
d="M107.782834,4.91476009 C111.16323,-0.155833631 118.014111,-1.52600976 123.084704,1.85438606 C128.155298,5.23478187 129.525474,12.0856625 126.145078,17.1562562 L64.5253312,123.085877 C60.662855,128.879591 52.465466,129.691293 47.5417556,124.767582 L3.23188204,89.4577087 C-1.07729401,85.1485327 -1.07729401,78.1619779 3.23188204,73.8528018 C7.54105809,69.5436258 14.5276129,69.5436258 18.8367889,73.8528018 L53.6283699,99.643429 L107.782834,4.91476009 Z"
id="Path-4"

View File

@ -1,13 +0,0 @@
import React from "react";
// https://discord.com/branding
export default function DiscordIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36">
<path
fill="currentColor"
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
);
}

View File

@ -1,20 +0,0 @@
import React from "react";
// https://github.com/logos
export default function GitHubIcon() {
return (
<svg
width="98"
height="96"
viewBox="0 0 98 96"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -1,20 +0,0 @@
import React from "react";
// https://www.migadu.com/svg/logo_bg.svg
export default function MigaduIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fillRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit={2}
clipRule="evenodd"
viewBox="0 0 415 349"
>
<path
fill="currentColor"
d="M244.75086422 348.3169321l-74.66970891.13434972-.20802007-113.77942005c1.68490153-30.73219447-16.74452614-54.87687007-47.61447477-56.04309707-18.69104704-.70603615-48.77863129 14.51785772-47.40962266 54.1403628 2.27958386 13.48489808 11.02256637 27.6516038 23.33561618 34.76524588 5.96894497 2.99415378 13.0523201 5.05841775 21.20064188 6.21929758 1.00064603.0642412 2.02105696.10203019 3.05792476.11007084l.00049907.27737388.42275408.04877057-.17002297 74.34665265-.1188864-.00639026.00000594.00330207C41.90779697 346.73036845-.82135413 282.37044643.16572087 221.6698232l-.00648785-3.60586044c-.01158759-.93446799-.01322144-1.84253724-.0048956-2.72090568-.01813527-13.74983137-.0434497-25.98399478-.08764166-43.20426606-.0013546-.75287196.0104753-1.51897597.03547784-2.30491616l-.10376496-50.33012717C1.41495687 53.41005395 55.4632909.11958949 121.93065794-.00000214c64.55216642-.1161457 117.56801589 49.96027937 122.08713264 113.39832671l.31397589.15463287c14.5939395-6.20445116 30.64246059-9.64767795 47.48962173-9.67799025l.78919473-.00141997c67.06911019.3052941 121.48637072 54.79077748 121.60722189 121.95818335l.04528936 32.51220498c.07884347 56.66686486.06505123 71.02433651.1745432 89.66768709l-74.66970891.1343497-.22422783-122.787467c-.73513767-25.01193853-19.72143626-44.82988648-44.42788174-46.81951503l-6.60414.01188252-.30703904.03027117c-2.13273916.22507674-4.25538198.5558019-6.34804476 1.03176467-3.89817024.88206518-7.69644066 2.22954399-11.2693623 4.02570031-4.6220412 2.3197727-8.86297784 5.39833827-12.49193835 9.08338558-5.5110203 5.59043208-9.56991873 12.57503157-11.72580294 20.12416492-.79737077 2.79829702-1.3403455 5.67208417-1.61581096 8.56850457-.15237519 1.56876249-.21557667 3.14727077-.2226489 4.72237598l.21983257 122.17989207zM74.54003064 143.61350486c9.74771064-.0175386 17.67371156 7.88328793 17.69124422 17.6276965.0175386 9.74771064-7.87997992 17.6770077-17.62769056 17.6945463-9.74771064.0175386-17.67371156-7.88328794-17.69125016-17.63099858-.01753266-9.74440857 7.87998586-17.67370562 17.6276965-17.69124422z"
/>
</svg>
);
}

View File

@ -1,72 +0,0 @@
import React from "react";
export default function NotNetIcon() {
return (
<svg
width="4"
height="4"
viewBox="0 0 4 4"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
>
<g id="notnet">
<g id="red">
<rect
fill="currentColor"
id="rect1"
width="2"
height="1"
x="1"
y="0"
/>
<rect
fill="currentColor"
id="rect2"
width="1"
height="2"
x="0"
y="1"
/>
<rect
fill="currentColor"
id="rect3"
width="1"
height="1"
x="1"
y="2"
/>
</g>
<g id="blue">
<rect
fill="currentColor"
id="rect4"
width="2"
height="1"
x="-3"
y="-4"
transform="scale(-1)"
/>
<rect
fill="currentColor"
id="rect5"
width="1"
height="2"
x="-4"
y="-3"
transform="scale(-1)"
/>
<rect
fill="currentColor"
id="rect6"
width="1"
height="1"
x="-3"
y="-2"
transform="scale(-1)"
/>
</g>
</g>
</svg>
);
}

View File

@ -1,26 +0,0 @@
import React from "react";
// https://tailscale.com/files/dist/tailscale-press-kit.zip
export default function TailscaleIcon() {
return (
<svg
fill="none"
height="120"
viewBox="30 30 60 60"
width="120"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="currentColor">
<circle cx="40.625" cy="59.5" r="6.625" />
<circle cx="60.4999" cy="59.5" r="6.625" />
<circle cx="40.625" cy="79.375" opacity=".2" r="6.625" />
<circle cx="80.375" cy="79.375" opacity=".2" r="6.625" />
<circle cx="60.4999" cy="79.375" r="6.625" />
<circle cx="80.375" cy="59.5" r="6.625" />
<circle cx="40.625" cy="39.625" opacity=".2" r="6.625" />
<circle cx="60.4999" cy="39.625" opacity=".2" r="6.625" />
<circle cx="80.375" cy="39.625" opacity=".2" r="6.625" />
</g>
</svg>
);
}

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,7 +1,7 @@
import sharp from "sharp";
export async function ensureJpg(avatar: Buffer) {
const img = await sharp(avatar).toFormat("jpeg").resize(1024, 1024);
const img = await sharp(avatar).toFormat("jpeg").resize(512, 512);
const buf = await img.toBuffer();
return buf.toString("base64");
}

View File

@ -1,9 +1,7 @@
import pino from "pino";
if (global.logger == null) {
global.logger = pino();
}
const logger = pino();
export function getLogger(name: string) {
return global.logger.child({ name });
return logger.child({ name });
}

View File

@ -1,71 +1,107 @@
import { z } from "zod";
import * as Yup from "yup";
const USERNAME = z
.string()
.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()
const REQUIRED = "Required.";
const USERNAME = Yup.string()
.required(REQUIRED)
.min(1, "Username is too short.");
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 1 MB.",
(value) => {
if (value == null) return true;
try {
const buf = Buffer.from(data, "base64");
return buf.length <= 2_000_000;
const buf = Buffer.from(value, "base64");
return buf.length <= 1_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;
}