Compare commits
No commits in common. "main" and "tic80" have entirely different histories.
68 changed files with 1668 additions and 5202 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -38,6 +38,3 @@ database.db*
|
|||
|
||||
# graphql-codegen
|
||||
src/__generated__
|
||||
|
||||
# NixOS
|
||||
result
|
||||
|
|
37
README.md
37
README.md
|
@ -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).
|
||||
|
|
26
codegen.ts
26
codegen.ts
|
@ -4,32 +4,24 @@ 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"
|
||||
: {
|
||||
[`http://${process.env.LDAP_HOST}:17170/api/graphql`]: {
|
||||
headers: {
|
||||
// can't make the request automatically (await on top level)
|
||||
Authorization: `Bearer ${process.env.GRAPHQL_CODEGEN_AUTH}`
|
||||
}
|
||||
}
|
||||
},
|
||||
schema: {
|
||||
[`http://${process.env.LDAP_HOST}:17170/api/graphql`]: {
|
||||
headers: {
|
||||
// can't make the request automatically (await on top level)
|
||||
Authorization: `Bearer ${process.env.GRAPHQL_CODEGEN_AUTH}`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
documents: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
generates: {
|
||||
"./src/__generated__/": {
|
||||
preset: "client",
|
||||
plugins: [],
|
||||
presetConfig: {
|
||||
gqlTagName: "gql"
|
||||
}
|
||||
},
|
||||
"introspection.json": {
|
||||
plugins: ["introspection"]
|
||||
}
|
||||
},
|
||||
ignoreNoDocuments: true
|
||||
|
|
3
environment.d.ts
vendored
3
environment.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
61
flake.lock
61
flake.lock
|
@ -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
182
flake.nix
|
@ -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 = { }; };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
2123
introspection.json
2123
introspection.json
File diff suppressed because it is too large
Load diff
|
@ -2,8 +2,7 @@
|
|||
const nextConfig = {
|
||||
experimental: {
|
||||
appDir: true
|
||||
},
|
||||
output: "standalone"
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
|
2564
package-lock.json
generated
2564
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
|
|
|
@ -4,7 +4,7 @@ generator client {
|
|||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
url = "file:./database.db"
|
||||
}
|
||||
|
||||
model AuthTicket {
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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 };
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export type ActionResponse = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
|
@ -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 };
|
||||
}
|
29
src/app/api/login/route.ts
Normal file
29
src/app/api/login/route.ts
Normal 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 }));
|
||||
}
|
|
@ -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 {
|
||||
ok: false,
|
||||
error: "usernameTaken"
|
||||
};
|
||||
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 }
|
||||
);
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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 {
|
||||
ok: false,
|
||||
error: "avatarBig"
|
||||
};
|
||||
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
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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");
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import ColorChanger from "@/components/ColorChanger";
|
||||
import "./globals.css";
|
||||
import "./fonts.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "gluestick",
|
||||
|
|
|
@ -1,34 +1,54 @@
|
|||
"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);
|
||||
if (res.ok) {
|
||||
document.cookie = `ticket=${res.ticket}; path=/;`;
|
||||
window.location.href = "/me";
|
||||
} else {
|
||||
// only error is invalidCredentials, I am lazy
|
||||
setGlobalError("Invalid credentials.");
|
||||
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";
|
||||
} else {
|
||||
// only error is invalidCredentials, I am lazy
|
||||
setGlobalError("Invalid credentials.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGlobalError("shits fucked up yo");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,7 +57,7 @@ export default function LoginForm() {
|
|||
<Formik
|
||||
initialValues={{ username: "", password: "" }}
|
||||
onSubmit={handleFormSubmit}
|
||||
validationSchema={toFormikValidationSchema(loginSchema)}
|
||||
validationSchema={loginSchema}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,142 +106,205 @@ 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>
|
||||
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null}
|
||||
<Input
|
||||
type="text"
|
||||
name="username"
|
||||
label="Username"
|
||||
defaultValue={info.username}
|
||||
disabled
|
||||
title="You can't change your username."
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
name="displayName"
|
||||
label="Display name"
|
||||
defaultValue={info.displayName}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
defaultValue={info.email}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className={styles.userName}>{info.username}</h2>
|
||||
<div className={styles.rightGrid}>
|
||||
<div className={styles.profile}>
|
||||
{madeProfileChanges ? (
|
||||
<Toast>Saved your changes.</Toast>
|
||||
) : null}
|
||||
<Input
|
||||
type="text"
|
||||
name="username"
|
||||
label="Username"
|
||||
defaultValue={info.username}
|
||||
disabled
|
||||
hint="This can’t be changed."
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
name="displayName"
|
||||
label="Display name"
|
||||
defaultValue={info.displayName}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
defaultValue={info.email}
|
||||
/>
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<hr className={styles.spacer} />
|
||||
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
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>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
className={styles.fancyInput}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,33 +41,49 @@ 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);
|
||||
if (res.ok) {
|
||||
router.replace("/me");
|
||||
} else {
|
||||
if (res.error !== null) {
|
||||
switch (res.error) {
|
||||
case "avatarBig":
|
||||
setFieldError(
|
||||
"avatar",
|
||||
"avatar was too big, but only the server caught you what the fuck are you doing!!"
|
||||
);
|
||||
break;
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
case "usernameTaken":
|
||||
setFieldError("username", "Username is already taken.");
|
||||
break;
|
||||
try {
|
||||
const res: RegisterResponse = await resp.json();
|
||||
|
||||
default:
|
||||
setGlobalError("Unknown error: " + res.error);
|
||||
break;
|
||||
if (res.ok) {
|
||||
router.replace("/me");
|
||||
} else {
|
||||
if (res.error !== null) {
|
||||
switch (res.error) {
|
||||
case "avatarBig":
|
||||
setFieldError(
|
||||
"avatar",
|
||||
"avatar was too big, but only the server caught you what the fuck are you doing!!"
|
||||
);
|
||||
break;
|
||||
|
||||
case "usernameTaken":
|
||||
setFieldError("username", "Username is already taken.");
|
||||
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 = "/";
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
.formRow label {
|
||||
display: block;
|
||||
font-variant: all-small-caps;
|
||||
font-size: 105%;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.logo {
|
||||
max-width: 700px;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
.form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form :is(button, input)[type="submit"] {
|
||||
background: var(--bg-dark);
|
||||
border: 0;
|
||||
|
|
|
@ -5,7 +5,7 @@ export default function PrettyForm({
|
|||
globalError,
|
||||
children
|
||||
}: {
|
||||
globalError?: string | null;
|
||||
globalError: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
118
src/schemas.ts
118
src/schemas.ts
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue