Compare commits
30 Commits
Author | SHA1 | Date |
---|---|---|
Julian | 940e2621bc | |
Julian | 4ce2931348 | |
Skip R. | 33e680a43f | |
Skip R. | dac227c937 | |
Skip R. | 515d874410 | |
Julian | d05961ad15 | |
Julian | 56e11c4d76 | |
Julian | 850f4ba9ab | |
Julian | fbe2222d1b | |
Julian | 5af2762e12 | |
Julian | 509b4a8f42 | |
Julian | 3b11a40928 | |
Julian | 45decdb110 | |
Julian | 1340bf531a | |
Julian | 3e24c99db4 | |
Julian | 7cb7838d4a | |
Julian | 26f5051a8e | |
Julian | 457c33e4a1 | |
Julian | 42f1b54fbd | |
Julian | b38a73eeac | |
Julian | f5640f41f3 | |
Julian | aec287c003 | |
Julian | 4315459e87 | |
Julian | 9aa36dd589 | |
Julian | 15360eb6b9 | |
Julian | da5373ef25 | |
Skip R. | 1e282617a9 | |
Skip R. | 967bb2a2d2 | |
Skip R. | fd79df9ec1 | |
Julian | cbcb8268b0 |
|
@ -38,3 +38,6 @@ database.db*
|
||||||
|
|
||||||
# graphql-codegen
|
# graphql-codegen
|
||||||
src/__generated__
|
src/__generated__
|
||||||
|
|
||||||
|
# NixOS
|
||||||
|
result
|
||||||
|
|
37
README.md
37
README.md
|
@ -50,6 +50,7 @@ After cloning, create an `.env.local` with the following contents (in `key=value
|
||||||
- `LDAP_BIND_PASSWORD`: the password of the bind user
|
- `LDAP_BIND_PASSWORD`: the password of the bind user
|
||||||
- `BASE_DOMAIN`: the domain gluestick is deployed on, with a protocol and trailing slash
|
- `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/`
|
- 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:
|
Example config:
|
||||||
|
|
||||||
|
@ -69,6 +70,7 @@ GITHUB_TOKEN=redacted
|
||||||
GITHUB_ORG=n2pm
|
GITHUB_ORG=n2pm
|
||||||
|
|
||||||
BASE_DOMAIN=https://gluestick.n2.pm/
|
BASE_DOMAIN=https://gluestick.n2.pm/
|
||||||
|
DATABASE_URL=file:./database.db
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generating code
|
### Generating code
|
||||||
|
@ -93,7 +95,7 @@ export GRAPHQL_CODEDGEN_AUTH=...
|
||||||
Then, generate the GraphQL and database code:
|
Then, generate the GraphQL and database code:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm run graphql-codegen
|
GRAPHQL_USE_INTROSPECTION=true npm run graphql-codegen
|
||||||
npm run prisma-generate
|
npm run prisma-generate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -108,7 +110,36 @@ npm run start
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
You'll want to run these two commands at the same time:
|
### 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
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Next.js hot reload
|
# Next.js hot reload
|
||||||
|
@ -119,5 +150,3 @@ npm run dev | pino-pretty
|
||||||
# Only required if working on GraphQL code
|
# Only required if working on GraphQL code
|
||||||
npm run graphql-codegen -- -w
|
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,24 +4,32 @@ import { CodegenConfig } from "@graphql-codegen/cli";
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
dotenv.config({ path: ".env.local" });
|
dotenv.config({ path: ".env.local" });
|
||||||
|
|
||||||
|
const useIntrospection = ["1", "true"].includes(
|
||||||
|
process.env.GRAPHQL_USE_INTROSPECTION?.toLowerCase() ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
const config: CodegenConfig = {
|
const config: CodegenConfig = {
|
||||||
schema: {
|
schema: useIntrospection
|
||||||
[`http://${process.env.LDAP_HOST}:17170/api/graphql`]: {
|
? "introspection.json"
|
||||||
headers: {
|
: {
|
||||||
// can't make the request automatically (await on top level)
|
[`http://${process.env.LDAP_HOST}:17170/api/graphql`]: {
|
||||||
Authorization: `Bearer ${process.env.GRAPHQL_CODEGEN_AUTH}`
|
headers: {
|
||||||
}
|
// can't make the request automatically (await on top level)
|
||||||
}
|
Authorization: `Bearer ${process.env.GRAPHQL_CODEGEN_AUTH}`
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
documents: ["src/**/*.ts", "src/**/*.tsx"],
|
documents: ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
generates: {
|
generates: {
|
||||||
"./src/__generated__/": {
|
"./src/__generated__/": {
|
||||||
preset: "client",
|
preset: "client",
|
||||||
plugins: [],
|
|
||||||
presetConfig: {
|
presetConfig: {
|
||||||
gqlTagName: "gql"
|
gqlTagName: "gql"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"introspection.json": {
|
||||||
|
plugins: ["introspection"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ignoreNoDocuments: true
|
ignoreNoDocuments: true
|
||||||
|
|
|
@ -2,11 +2,13 @@ import { PrismaClient } from "@prisma/client";
|
||||||
import { Client as LDAPClient } from "ldapts";
|
import { Client as LDAPClient } from "ldapts";
|
||||||
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
||||||
import { LLDAPAuthResponse } from "@/ldap";
|
import { LLDAPAuthResponse } from "@/ldap";
|
||||||
|
import { Logger } from "pino";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var prisma: PrismaClient | undefined;
|
var prisma: PrismaClient | undefined;
|
||||||
var ldapClient: LDAPClient | undefined;
|
var ldapClient: LDAPClient | undefined;
|
||||||
var authResponse: LLDAPAuthResponse | undefined;
|
var authResponse: LLDAPAuthResponse | undefined;
|
||||||
|
var logger: Logger;
|
||||||
|
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
|
@ -25,6 +27,7 @@ declare global {
|
||||||
GITHUB_ORG: string;
|
GITHUB_ORG: string;
|
||||||
|
|
||||||
BASE_DOMAIN: string;
|
BASE_DOMAIN: string;
|
||||||
|
API_TOKEN?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
|
@ -0,0 +1,182 @@
|
||||||
|
{
|
||||||
|
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
|
@ -2,7 +2,8 @@
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
experimental: {
|
experimental: {
|
||||||
appDir: true
|
appDir: true
|
||||||
}
|
},
|
||||||
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -25,18 +25,20 @@
|
||||||
"formik": "^2.2.9",
|
"formik": "^2.2.9",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"ldapts": "^4.2.5",
|
"ldapts": "^4.2.5",
|
||||||
"next": "13.3.1",
|
"next": "^13.4.2-canary.4",
|
||||||
"pino": "^8.11.0",
|
"pino": "^8.11.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"sharp": "^0.32.0",
|
"sharp": "^0.32.0",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"yup": "^1.1.1"
|
"zod": "^3.21.4",
|
||||||
|
"zod-formik-adapter": "^1.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^3.3.1",
|
"@graphql-codegen/cli": "^3.3.1",
|
||||||
"@graphql-codegen/client-preset": "^3.0.1",
|
"@graphql-codegen/client-preset": "^3.0.1",
|
||||||
|
"@graphql-codegen/introspection": "^3.0.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"pino-pretty": "^10.0.0",
|
"pino-pretty": "^10.0.0",
|
||||||
"prisma": "^4.13.0"
|
"prisma": "^4.13.0"
|
||||||
|
|
|
@ -4,7 +4,7 @@ generator client {
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = "file:./database.db"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuthTicket {
|
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.
|
@ -0,0 +1,29 @@
|
||||||
|
"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 };
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type ActionResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
|
@ -0,0 +1,26 @@
|
||||||
|
"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 };
|
||||||
|
}
|
|
@ -1,38 +1,47 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
import * as ldap from "@/ldap";
|
import * as ldap from "@/ldap";
|
||||||
import prisma from "@/prisma";
|
import prisma from "@/prisma";
|
||||||
import { getUser } from "@/auth/auth";
|
import { getUser } from "@/auth/auth";
|
||||||
import { getLogger } from "@/logger";
|
import { getLogger } from "@/logger";
|
||||||
import { registerServerSchema } from "@/schemas";
|
import { RegisterSchema, registerSchema } from "@/schemas";
|
||||||
|
import { ActionResponse } from ".";
|
||||||
|
|
||||||
const logger = getLogger("/api/register");
|
const logger = getLogger("/actions/register");
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export default async function register(
|
||||||
|
data: RegisterSchema
|
||||||
|
): Promise<ActionResponse> {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
if (user == null) return new Response(null, { status: 401 });
|
|
||||||
|
|
||||||
// user already has an account, don't re-register
|
if (user == null) {
|
||||||
|
return { ok: false, error: "invalidAuth" };
|
||||||
|
}
|
||||||
|
|
||||||
if (user.username != null) {
|
if (user.username != null) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ username: user.username, id: user.id },
|
{ username: user.username, id: user.id },
|
||||||
`user tried to register twice`
|
`user tried to register twice`
|
||||||
);
|
);
|
||||||
return new Response(null, { status: 403 });
|
return { ok: false, error: "invalidAuth" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, displayName, email, password, avatar } =
|
const { username, displayName, email, password, avatar } =
|
||||||
await registerServerSchema.validate(await request.json());
|
await registerSchema.parse(data);
|
||||||
let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null;
|
let avatarBuf = null;
|
||||||
|
if (avatar != null) {
|
||||||
|
const parts = avatar.split(",");
|
||||||
|
const data = parts.length === 2 ? parts[1] : parts[0];
|
||||||
|
avatarBuf = Buffer.from(data, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
const users = await ldap.getUsers();
|
const users = await ldap.getUsers();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (user.id.toLowerCase() === username.toLowerCase()) {
|
if (user.id.toLowerCase() === username.toLowerCase()) {
|
||||||
return new Response(
|
return {
|
||||||
JSON.stringify({
|
ok: false,
|
||||||
ok: false,
|
error: "usernameTaken"
|
||||||
error: "usernameTaken"
|
};
|
||||||
}),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,10 +58,5 @@ export async function POST(request: Request) {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(outputUser, "registered user");
|
logger.info(outputUser, "registered user");
|
||||||
return new Response(
|
return { ok: true };
|
||||||
JSON.stringify({
|
|
||||||
ok: true
|
|
||||||
}),
|
|
||||||
{ status: 201 }
|
|
||||||
);
|
|
||||||
}
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ValidAuthProvider } from "@/auth/AuthProvider";
|
||||||
import {
|
import {
|
||||||
AuthState,
|
AuthState,
|
||||||
getAuthState,
|
getAuthState,
|
||||||
|
@ -35,8 +38,7 @@ async function deleteUser(id: number) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export default async function unlink(provider?: ValidAuthProvider) {
|
||||||
export async function POST(request: Request) {
|
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
|
|
||||||
if (authState == AuthState.Registering) {
|
if (authState == AuthState.Registering) {
|
||||||
|
@ -49,30 +51,22 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
await deleteUser(registeringUser.id);
|
await deleteUser(registeringUser.id);
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
if (user == null) return new Response(null, { status: 401 });
|
if (user == null) return;
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const provider = searchParams.get("provider");
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "discord":
|
case "Discord":
|
||||||
const discord = await user.getDiscord();
|
const discord = await user.getDiscord();
|
||||||
if (discord == null) return new Response(null, { status: 400 });
|
if (discord == null) return;
|
||||||
await unlinkDiscord(await discord.getId());
|
await unlinkDiscord(await discord.getId());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "github":
|
case "GitHub":
|
||||||
const github = await user.getGitHub();
|
const github = await user.getGitHub();
|
||||||
if (github == null) return new Response(null, { status: 400 });
|
if (github == null) return;
|
||||||
await unlinkGitHub(await github.getId());
|
await unlinkGitHub(await github.getId());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
|
||||||
return new Response(null, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
|
||||||
}
|
}
|
|
@ -1,30 +1,28 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { AboutMeSchema, aboutMeSchema } from "@/schemas";
|
||||||
|
import { ActionResponse } from ".";
|
||||||
|
import { getLogger } from "@/logger";
|
||||||
import { getUser } from "@/auth/auth";
|
import { getUser } from "@/auth/auth";
|
||||||
import { getUserInfo, updateUser } from "@/ldap";
|
import { getUserInfo, updateUser } from "@/ldap";
|
||||||
import { getLogger } from "@/logger";
|
|
||||||
|
|
||||||
type RequestBody = {
|
const logger = getLogger("/actions/update");
|
||||||
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();
|
const user = await getUser();
|
||||||
if (user == null) return new Response(null, { status: 401 });
|
if (user == null) {
|
||||||
|
return { ok: false, error: "invalidAuth" };
|
||||||
|
}
|
||||||
|
|
||||||
const userInfo = await getUserInfo(user);
|
const userInfo = await getUserInfo(user);
|
||||||
if (userInfo == null) {
|
if (userInfo == null) {
|
||||||
// no user info = hasn't registered yet
|
// no user info = hasn't registered yet
|
||||||
return new Response(null, { status: 409 });
|
return { ok: false, error: "invalidAuth" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { displayName, email, avatar } = await aboutMeSchema.parse(data);
|
||||||
displayName,
|
|
||||||
email,
|
|
||||||
avatar: avatarBase64
|
|
||||||
} = (await request.json()) as RequestBody;
|
|
||||||
|
|
||||||
let changeDisplayName = false;
|
let changeDisplayName = false;
|
||||||
if (
|
if (
|
||||||
|
@ -47,25 +45,24 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
let avatarBuf = undefined;
|
let avatarBuf = undefined;
|
||||||
if (
|
if (
|
||||||
avatarBase64 !== undefined &&
|
avatar !== undefined &&
|
||||||
typeof avatarBase64 === "string" &&
|
typeof avatar === "string" &&
|
||||||
avatarBase64 !== userInfo.avatar
|
avatar !== userInfo.avatar
|
||||||
) {
|
) {
|
||||||
avatarBuf = Buffer.from(avatarBase64, "base64");
|
const parts = avatar.split(",");
|
||||||
|
const data = parts.length === 2 ? parts[1] : parts[0];
|
||||||
|
avatarBuf = Buffer.from(data, "base64");
|
||||||
|
|
||||||
if (avatarBuf.length > 1_000_000) {
|
if (avatarBuf.length > 2_000_000) {
|
||||||
return new Response(
|
return {
|
||||||
JSON.stringify({
|
ok: false,
|
||||||
ok: false,
|
error: "avatarBig"
|
||||||
error: "avatarBig"
|
};
|
||||||
}),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changeDisplayName && !changeEmail && !avatarBuf) {
|
if (!changeDisplayName && !changeEmail && !avatarBuf) {
|
||||||
return new Response(null, { status: 200 });
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateUser(
|
await updateUser(
|
||||||
|
@ -85,12 +82,5 @@ export async function POST(request: Request) {
|
||||||
"updated user"
|
"updated user"
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(
|
return { ok: true };
|
||||||
JSON.stringify({
|
|
||||||
ok: true
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
|
@ -1,29 +0,0 @@
|
||||||
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 }));
|
|
||||||
}
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
@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,6 +48,16 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--theme-transition: 0.5s ease;
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
@ -62,27 +72,46 @@ body {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family: lunchtype, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-stretch: expanded;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
input,
|
input,
|
||||||
|
button,
|
||||||
label {
|
label {
|
||||||
transition: background-color var(--theme-transition),
|
transition: background-color var(--theme-transition),
|
||||||
color var(--theme-transition);
|
color var(--theme-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:disabled, button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
input,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: 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 {
|
input::placeholder {
|
||||||
|
@ -94,3 +123,8 @@ a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import ColorChanger from "@/components/ColorChanger";
|
import ColorChanger from "@/components/ColorChanger";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import "./fonts.css";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "gluestick",
|
title: "gluestick",
|
||||||
|
|
|
@ -1,54 +1,34 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import login from "@/actions/login";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
import PrettyForm from "@/components/PrettyForm";
|
import PrettyForm from "@/components/PrettyForm";
|
||||||
import { LoginFormValues, loginSchema } from "@/schemas";
|
import { LoginSchema, loginSchema } from "@/schemas";
|
||||||
import { Form, Formik, FormikHelpers, FormikValues } from "formik";
|
import { Form, Formik, FormikHelpers, FormikValues } from "formik";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
async function handleFormSubmit(
|
async function handleFormSubmit(
|
||||||
{ username, password }: LoginFormValues,
|
data: LoginSchema,
|
||||||
{ setSubmitting }: FormikHelpers<LoginFormValues>
|
{ setSubmitting }: FormikHelpers<LoginSchema>
|
||||||
) {
|
) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
if (username === "greets") {
|
if (data.username === "greets") {
|
||||||
window.location.href = "/sekrit";
|
window.location.href = "/sekrit";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = await fetch("/api/login", {
|
const res = await login(data);
|
||||||
method: "POST",
|
if (res.ok) {
|
||||||
headers: {
|
document.cookie = `ticket=${res.ticket}; path=/;`;
|
||||||
"Content-Type": "application/json"
|
window.location.href = "/me";
|
||||||
},
|
} else {
|
||||||
body: JSON.stringify({
|
// only error is invalidCredentials, I am lazy
|
||||||
username,
|
setGlobalError("Invalid credentials.");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,7 +37,7 @@ export default function LoginForm() {
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{ username: "", password: "" }}
|
initialValues={{ username: "", password: "" }}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
validationSchema={loginSchema}
|
validationSchema={toFormikValidationSchema(loginSchema)}
|
||||||
>
|
>
|
||||||
{({ isSubmitting }) => (
|
{({ isSubmitting }) => (
|
||||||
<Form>
|
<Form>
|
||||||
|
|
|
@ -1,28 +1,37 @@
|
||||||
.content {
|
.content {
|
||||||
max-width: 700px;
|
width: min-content;
|
||||||
margin: 0 auto;
|
margin: 2rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profileGrid {
|
.profileGrid {
|
||||||
/* todo */
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
column-gap: 2rem;
|
||||||
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.profileTower *:first-child {
|
||||||
width: 400px;
|
margin-top: 0 !important;
|
||||||
|
|
||||||
background-color: var(--fg-darker);
|
|
||||||
height: 1px;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
margin: 1rem auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout {
|
.connections {
|
||||||
background: var(--bg-dark);
|
margin-top: 1rem;
|
||||||
border: 0;
|
}
|
||||||
border-radius: 0.15rem;
|
|
||||||
cursor: pointer;
|
.connections > *:nth-child(2) {
|
||||||
padding: 0.5em 1em;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stolen from prettyform */
|
/* stolen from prettyform */
|
||||||
|
@ -32,6 +41,8 @@
|
||||||
border-radius: 0.15rem;
|
border-radius: 0.15rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authProviderList {
|
.authProviderList {
|
||||||
|
@ -48,25 +59,32 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the !importants here piss me off but it wouldn't accept the property otherwise */
|
.multiButtons {
|
||||||
.progress {
|
margin: 1rem 0;
|
||||||
background: linear-gradient(
|
white-space: nowrap;
|
||||||
to right,
|
display: flex;
|
||||||
var(--fg-darker) 50%,
|
justify-content: space-between;
|
||||||
var(--bg-dark) 50%
|
}
|
||||||
) !important;
|
|
||||||
background-size: 200% 100% !important;
|
|
||||||
background-position: right bottom !important;
|
|
||||||
transition: all 0s linear !important;
|
|
||||||
|
|
||||||
|
.spacer {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.15rem;
|
border-bottom: 1px solid var(--fg-darker);
|
||||||
cursor: pointer;
|
width: 100%;
|
||||||
padding: 0.5em 1em;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* when clicked */
|
/* stack if we're out of space */
|
||||||
.progress:active {
|
@media (max-width: 800px) {
|
||||||
transition: all 3s linear !important;
|
.profileGrid {
|
||||||
background-position: left bottom !important;
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileGrid > * {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,100 +5,35 @@ import { UserInfo } from "@/ldap";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import styles from "./AboutMe.module.css";
|
import styles from "./AboutMe.module.css";
|
||||||
import AvatarChanger from "@/components/AvatarChanger";
|
import AvatarChanger from "@/components/AvatarChanger";
|
||||||
import Input from "@/components/Input";
|
import Input, { Hint, Label } from "@/components/Input";
|
||||||
import { Form, Formik, FormikHelpers } from "formik";
|
import { Form, Formik, FormikHelpers } from "formik";
|
||||||
import {
|
|
||||||
AboutMeFormValues,
|
|
||||||
PasswordUpdateFormValues,
|
|
||||||
aboutMeSchema,
|
|
||||||
passwordUpdateSchema
|
|
||||||
} from "@/schemas";
|
|
||||||
import PrettyForm from "@/components/PrettyForm";
|
import PrettyForm from "@/components/PrettyForm";
|
||||||
import Toast from "@/components/Toast";
|
import Toast from "@/components/Toast";
|
||||||
import { AuthProviderState } from "@/auth/AuthProvider";
|
import { AuthProviderState } from "@/auth/AuthProvider";
|
||||||
import { exec } from "child_process";
|
import Connection from "@/components/Connection";
|
||||||
|
import DiscordIcon from "@/components/icons/DiscordIcon";
|
||||||
type UpdateResponse = {
|
import GitHubIcon from "@/components/icons/GitHubIcon";
|
||||||
ok: boolean;
|
import TailscaleIcon from "@/components/icons/TailscaleIcon";
|
||||||
error?: string;
|
import MigaduIcon from "@/components/icons/MigaduIcon";
|
||||||
};
|
import { AboutMeSchema, aboutMeSchema } from "@/schemas";
|
||||||
|
import update from "@/actions/update";
|
||||||
async function fileAsBase64(f: File) {
|
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||||
const reader = new FileReader();
|
import { useRouter } from "next/navigation";
|
||||||
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({
|
export default function AboutMe({
|
||||||
info,
|
info,
|
||||||
providers
|
providers: [discordState, githubState]
|
||||||
}: {
|
}: {
|
||||||
info: UserInfo;
|
info: UserInfo;
|
||||||
providers: AuthProviderState[];
|
providers: AuthProviderState[];
|
||||||
}) {
|
}) {
|
||||||
|
// TODO: Reimplement password changing.
|
||||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
const [madeProfileChanges, setMadeChanges] = React.useState(false);
|
const [madeProfileChanges, setMadeChanges] = React.useState(false);
|
||||||
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
|
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const initialValues: AboutMeFormValues = {
|
const initialValues: AboutMeSchema = {
|
||||||
username: info.username,
|
username: info.username,
|
||||||
displayName: info.displayName,
|
displayName: info.displayName,
|
||||||
email: info.email,
|
email: info.email,
|
||||||
|
@ -106,205 +41,142 @@ export default function AboutMe({
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleFormSubmit(
|
async function handleFormSubmit(
|
||||||
{ displayName, email, avatar }: AboutMeFormValues,
|
data: AboutMeSchema,
|
||||||
{ setSubmitting }: FormikHelpers<AboutMeFormValues>
|
{ setSubmitting }: FormikHelpers<AboutMeSchema>
|
||||||
) {
|
) {
|
||||||
setMadeChanges(false);
|
setMadeChanges(false);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const req = await fetch("/api/update", {
|
const res = await update(data);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
displayName,
|
|
||||||
email,
|
|
||||||
avatar: avatar != null ? avatar.split(",")[1] : null
|
|
||||||
})
|
|
||||||
});
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
||||||
try {
|
if (res.ok) {
|
||||||
const res: UpdateResponse = await req.json();
|
|
||||||
|
|
||||||
if (!res.ok && res.error !== null) {
|
|
||||||
switch (res.error) {
|
|
||||||
case "avatarBig":
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMadeChanges(true);
|
setMadeChanges(true);
|
||||||
} catch {
|
} else {
|
||||||
console.error(req);
|
if (res.error != undefined) {
|
||||||
}
|
setGlobalError("Unknown error: " + res.error);
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<h2 className={styles.userName}>{info.username}</h2>
|
|
||||||
<PrettyForm globalError={globalError}>
|
<PrettyForm globalError={globalError}>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
validationSchema={aboutMeSchema}
|
validationSchema={toFormikValidationSchema(aboutMeSchema)}
|
||||||
>
|
>
|
||||||
{({ isSubmitting }) => (
|
{({ isSubmitting }) => (
|
||||||
<Form className={styles.profileGrid}>
|
<Form className={styles.profileGrid}>
|
||||||
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null}
|
<div className={styles.profileTower}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="file"
|
||||||
name="username"
|
name="avatar"
|
||||||
label="Username"
|
customRender={(fieldProps) => (
|
||||||
defaultValue={info.username}
|
<AvatarChanger
|
||||||
disabled
|
currentAvatarBlob={fieldProps.field.value}
|
||||||
title="You can't change your username."
|
onChange={(newBlob) =>
|
||||||
/>
|
fieldProps.form.setFieldValue("avatar", newBlob)
|
||||||
<Input
|
}
|
||||||
type="text"
|
vertical
|
||||||
name="displayName"
|
/>
|
||||||
label="Display name"
|
)}
|
||||||
defaultValue={info.displayName}
|
/>
|
||||||
/>
|
</div>
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
label="Email"
|
|
||||||
defaultValue={info.email}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<div>
|
||||||
type="file"
|
<h2 className={styles.userName}>{info.username}</h2>
|
||||||
name="avatar"
|
<div className={styles.rightGrid}>
|
||||||
label="Avatar"
|
<div className={styles.profile}>
|
||||||
accept="image/png, image/jpeg"
|
{madeProfileChanges ? (
|
||||||
customRender={(fieldProps) => (
|
<Toast>Saved your changes.</Toast>
|
||||||
<AvatarChanger
|
) : null}
|
||||||
currentAvatarBlob={fieldProps.field.value}
|
<Input
|
||||||
onChange={(newBlob) =>
|
type="text"
|
||||||
fieldProps.form.setFieldValue("avatar", newBlob)
|
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
|
<hr className={styles.spacer} />
|
||||||
type="submit"
|
|
||||||
value="Save"
|
<input
|
||||||
className={styles.fancyInput}
|
type="submit"
|
||||||
disabled={isSubmitting}
|
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>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
</PrettyForm>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
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,6 +1,8 @@
|
||||||
import { DiscordAuthProvider } from "@/auth/discord";
|
import { DiscordAuthProvider } from "@/auth/discord";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
let url = `https://discord.com/oauth2/authorize`;
|
let url = `https://discord.com/oauth2/authorize`;
|
||||||
let state = v4();
|
let state = v4();
|
||||||
|
|
|
@ -21,11 +21,6 @@ export async function GET(request: Request) {
|
||||||
const id = await provider.getId();
|
const id = await provider.getId();
|
||||||
const permitted = await provider.isPermitted();
|
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,
|
// If someone clicked register on the front page with an existing account,
|
||||||
// wire it to their user via the auth ticket
|
// wire it to their user via the auth ticket
|
||||||
let gluestickId = null;
|
let gluestickId = null;
|
||||||
|
@ -35,6 +30,11 @@ export async function GET(request: Request) {
|
||||||
gluestickId = currentUser!.id;
|
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(
|
const userId = await DiscordAuthProvider.update(
|
||||||
id,
|
id,
|
||||||
tokenBody.access_token,
|
tokenBody.access_token,
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { GitHubAuthProvider } from "@/auth/github";
|
import { GitHubAuthProvider } from "@/auth/github";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
let url = `https://github.com/login/oauth/authorize`;
|
let url = `https://github.com/login/oauth/authorize`;
|
||||||
let state = v4();
|
let state = v4();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { getLogger } from "@/logger";
|
import { getLogger } from "@/logger";
|
||||||
import { GitHubAuthProvider } from "@/auth/github";
|
import { GitHubAuthProvider, inviteToGitHub } from "@/auth/github";
|
||||||
import {
|
import {
|
||||||
AuthState,
|
AuthState,
|
||||||
authTicketOAuth,
|
authTicketOAuth,
|
||||||
|
@ -21,11 +21,6 @@ export async function GET(request: Request) {
|
||||||
const id = await provider.getId();
|
const id = await provider.getId();
|
||||||
const permitted = await provider.isPermitted();
|
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,
|
// If someone clicked register on the front page with an existing account,
|
||||||
// wire it to their user via the auth ticket
|
// wire it to their user via the auth ticket
|
||||||
let gluestickId = null;
|
let gluestickId = null;
|
||||||
|
@ -35,6 +30,11 @@ export async function GET(request: Request) {
|
||||||
gluestickId = currentUser!.id;
|
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(
|
const userId = await GitHubAuthProvider.update(
|
||||||
id,
|
id,
|
||||||
tokenBody.access_token,
|
tokenBody.access_token,
|
||||||
|
@ -42,6 +42,7 @@ export async function GET(request: Request) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (gluestickId != null) {
|
if (gluestickId != null) {
|
||||||
|
await inviteToGitHub(provider);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -52,6 +53,16 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
const [user, authTicket] = await authTicketOAuth(userId);
|
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 username = await provider.getDisplayName();
|
||||||
const email = await provider.getEmail();
|
const email = await provider.getEmail();
|
||||||
const avatarUrl = await provider.getAvatar();
|
const avatarUrl = await provider.getAvatar();
|
||||||
|
|
|
@ -3,26 +3,36 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.content {
|
||||||
|
margin-top: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
font-stretch: expanded;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form div {
|
.content p {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form div label {
|
.icons svg {
|
||||||
margin-right: 1rem;
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form div input {
|
.icons svg:hover {
|
||||||
width: 15rem;
|
background-color: var(--bg-dark);
|
||||||
}
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
|
@ -1,21 +1,31 @@
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
import styles from "./page.module.css";
|
import styles from "./page.module.css";
|
||||||
import Image from "next/image";
|
import NotNetIcon from "@/components/icons/NotNetIcon";
|
||||||
|
import GitHubIcon from "@/components/icons/GitHubIcon";
|
||||||
|
import DiscordIcon from "@/components/icons/DiscordIcon";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Image src="/icon.svg" alt="gluestick logo" width="256" height="256" />
|
<Logo />
|
||||||
|
|
||||||
<p
|
<div className={styles.content}>
|
||||||
style={{
|
<p>login with</p>
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column"
|
<div className={styles.icons}>
|
||||||
}}
|
<a href="/login" title="NotNet">
|
||||||
>
|
<NotNetIcon />
|
||||||
<a href="/login">login</a>
|
</a>
|
||||||
<a href="/oauth/discord/login">register (discord)</a>
|
|
||||||
<a href="/oauth/github/login">register (github)</a>
|
<a href="/oauth/discord/login" title="Discord">
|
||||||
</p>
|
<DiscordIcon />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/oauth/github/login" title="GitHub">
|
||||||
|
<GitHubIcon />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,20 +2,17 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import styles from "./RegisterForm.module.css";
|
import styles from "./RegisterForm.module.css";
|
||||||
import { Form, Formik, FormikHelpers, yupToFormErrors } from "formik";
|
import { Form, Formik, FormikHelpers } from "formik";
|
||||||
import { RegisterFormValues, registerSchema } from "@/schemas";
|
import { registerSchema, RegisterSchema } from "@/schemas";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { fileAsBase64 } from "@/forms";
|
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
import PrettyForm from "@/components/PrettyForm";
|
import PrettyForm from "@/components/PrettyForm";
|
||||||
import HugeSubmit from "@/components/HugeSubmit";
|
import HugeSubmit from "@/components/HugeSubmit";
|
||||||
import AvatarChanger from "@/components/AvatarChanger";
|
import AvatarChanger from "@/components/AvatarChanger";
|
||||||
import { ValidAuthProvider } from "@/auth/AuthProvider";
|
import { ValidAuthProvider } from "@/auth/AuthProvider";
|
||||||
|
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||||
type RegisterResponse = {
|
import register from "@/actions/register";
|
||||||
ok: boolean;
|
import unlink from "@/actions/unlink";
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RegisterForm({
|
export default function RegisterForm({
|
||||||
initialDisplayName,
|
initialDisplayName,
|
||||||
|
@ -31,7 +28,7 @@ export default function RegisterForm({
|
||||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const initialValues: RegisterFormValues = {
|
const initialValues: RegisterSchema = {
|
||||||
username: "",
|
username: "",
|
||||||
displayName: initialDisplayName ?? "",
|
displayName: initialDisplayName ?? "",
|
||||||
email: initialEmail ?? "",
|
email: initialEmail ?? "",
|
||||||
|
@ -41,49 +38,33 @@ export default function RegisterForm({
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleFormSubmit(
|
async function handleFormSubmit(
|
||||||
{ avatar, username, displayName, email, password }: RegisterFormValues,
|
data: RegisterSchema,
|
||||||
{ setFieldError, setSubmitting }: FormikHelpers<RegisterFormValues>
|
{ setFieldError, setSubmitting }: FormikHelpers<RegisterSchema>
|
||||||
) {
|
) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
const resp = await fetch(`/api/register`, {
|
const res = await register(data);
|
||||||
method: "POST",
|
if (res.ok) {
|
||||||
headers: {
|
router.replace("/me");
|
||||||
"Content-Type": "application/json"
|
} else {
|
||||||
},
|
if (res.error !== null) {
|
||||||
body: JSON.stringify({
|
switch (res.error) {
|
||||||
username,
|
case "avatarBig":
|
||||||
displayName,
|
setFieldError(
|
||||||
email,
|
"avatar",
|
||||||
password,
|
"avatar was too big, but only the server caught you what the fuck are you doing!!"
|
||||||
avatar: avatar != null ? avatar.split(",")[1] : undefined
|
);
|
||||||
})
|
break;
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
case "usernameTaken":
|
||||||
const res: RegisterResponse = await resp.json();
|
setFieldError("username", "Username is already taken.");
|
||||||
|
break;
|
||||||
|
|
||||||
if (res.ok) {
|
default:
|
||||||
router.replace("/me");
|
setGlobalError("Unknown error: " + res.error);
|
||||||
} else {
|
break;
|
||||||
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);
|
setSubmitting(false);
|
||||||
|
@ -94,7 +75,7 @@ export default function RegisterForm({
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
validationSchema={registerSchema}
|
validationSchema={toFormikValidationSchema(registerSchema)}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ isSubmitting }) => (
|
{({ isSubmitting }) => (
|
||||||
|
@ -141,7 +122,7 @@ export default function RegisterForm({
|
||||||
(avatarSource != null
|
(avatarSource != null
|
||||||
? `We found your avatar from ${avatarSource}, but you can change it if you'd like.`
|
? `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 1 MB. "
|
" This will automatically be used as your avatar with supported services - maximum 2 MB. "
|
||||||
}
|
}
|
||||||
type="file"
|
type="file"
|
||||||
name="avatar"
|
name="avatar"
|
||||||
|
@ -163,7 +144,7 @@ export default function RegisterForm({
|
||||||
<a
|
<a
|
||||||
className={styles.bail}
|
className={styles.bail}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await fetch("/api/unlink", { method: "POST" });
|
await unlink();
|
||||||
document.cookie =
|
document.cookie =
|
||||||
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|
|
@ -20,10 +20,6 @@ function avatarUrlSource(url: URL): ValidAuthProvider | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatarUrlAllowed(url: URL): boolean {
|
|
||||||
return avatarUrlSource(url) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
searchParams
|
searchParams
|
||||||
}: {
|
}: {
|
||||||
|
@ -55,7 +51,7 @@ export default async function Page({
|
||||||
const blob = await req.blob();
|
const blob = await req.blob();
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
if (buffer.length <= 1_000_000) {
|
if (buffer.length <= 2_000_000) {
|
||||||
// I hope you are doing well, you deserve the best of luck while working on this project -Ari
|
// I hope you are doing well, you deserve the best of luck while working on this project -Ari
|
||||||
try {
|
try {
|
||||||
const jpg = await ensureJpg(buffer);
|
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
|
// Can't send the providers across the wire, do this instead
|
||||||
export type AuthProviderState = {
|
export type AuthProviderState = {
|
||||||
name: string;
|
name: ValidAuthProvider;
|
||||||
} & ({ connected: false } | { connected: true; id: string; username: string });
|
} & ({ connected: false } | { connected: true; id: string; username: string });
|
||||||
|
|
||||||
export abstract class AuthProvider {
|
export abstract class AuthProvider {
|
||||||
|
|
|
@ -68,7 +68,7 @@ export class DiscordAuthProvider extends AuthProvider {
|
||||||
async getAvatar(): Promise<string | null> {
|
async getAvatar(): Promise<string | null> {
|
||||||
const me = await this.getMe();
|
const me = await this.getMe();
|
||||||
return me.avatar !== null
|
return me.avatar !== null
|
||||||
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png`
|
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=1024`
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,18 @@ type GitHubUserResponse = {
|
||||||
email: string;
|
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 {
|
export class GitHubAuthProvider extends AuthProvider {
|
||||||
private async getMe(): Promise<GitHubUserResponse> {
|
private async getMe(): Promise<GitHubUserResponse> {
|
||||||
const req = await fetch("https://api.github.com/user", {
|
const req = await fetch("https://api.github.com/user", {
|
||||||
|
@ -27,16 +39,8 @@ export class GitHubAuthProvider extends AuthProvider {
|
||||||
|
|
||||||
async isPermitted(): Promise<boolean> {
|
async isPermitted(): Promise<boolean> {
|
||||||
const me = await this.getMe();
|
const me = await this.getMe();
|
||||||
const req = await fetch(
|
const members = await getMembers();
|
||||||
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`,
|
return members.some((user) => user.login === me.login);
|
||||||
{
|
|
||||||
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> {
|
async getDisplayName(): Promise<string> {
|
||||||
|
@ -139,3 +143,24 @@ export class GitHubAuthProvider extends AuthProvider {
|
||||||
return a.userId;
|
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,32 +2,44 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row nowrap;
|
flex-flow: row nowrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarChanger :is(img, svg) {
|
.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarChanger .currentAvatar,
|
||||||
|
.avatarChanger svg {
|
||||||
width: 3em;
|
width: 3em;
|
||||||
height: 3em;
|
height: 3em;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vertical.vertical .currentAvatar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: inherit;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
|
|
||||||
.avatarChanger button svg {
|
.avatarChanger button svg {
|
||||||
width: 1.2em;
|
width: 1.2em;
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarChanger input[type=file] {
|
.avatarChanger input[type="file"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploadButton {
|
.uploadButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.15rem;
|
border-radius: 0.15rem;
|
||||||
padding: 0.25em 1em;
|
padding: 0.5em 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import React, { ChangeEvent } from "react";
|
import React, { ChangeEvent } from "react";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
|
|
||||||
|
@ -8,10 +9,12 @@ import UserIcon from "./icons/UserIcon";
|
||||||
|
|
||||||
export default function AvatarChanger({
|
export default function AvatarChanger({
|
||||||
currentAvatarBlob,
|
currentAvatarBlob,
|
||||||
onChange
|
onChange,
|
||||||
|
vertical = false
|
||||||
}: {
|
}: {
|
||||||
currentAvatarBlob: string | null;
|
currentAvatarBlob: string | null;
|
||||||
onChange: (newAvatar: string) => void;
|
onChange: (newAvatar: string) => void;
|
||||||
|
vertical?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const input = React.useRef<HTMLInputElement>(null);
|
const input = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
@ -25,11 +28,20 @@ export default function AvatarChanger({
|
||||||
|
|
||||||
// I give you the most support and well wishes while you work on this project -Ari
|
// I give you the most support and well wishes while you work on this project -Ari
|
||||||
return (
|
return (
|
||||||
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
|
<div
|
||||||
|
className={classnames(
|
||||||
|
styles.avatarChanger,
|
||||||
|
vertical ? styles.vertical : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
{currentAvatarBlob != null ? (
|
{currentAvatarBlob != null ? (
|
||||||
<img src={currentAvatarBlob!} alt="Your avatar" />
|
<img
|
||||||
|
className={styles.currentAvatar}
|
||||||
|
src={currentAvatarBlob!}
|
||||||
|
alt="Your avatar"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserIcon />
|
<UserIcon className={styles.currentAvatar} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
.colorChanger {
|
.colorChanger {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
right: 10px;
|
||||||
bottom: 0;
|
bottom: 10px;
|
||||||
|
transition: filter 100ms ease-in-out;
|
||||||
padding: 8px;
|
filter: grayscale(100%) opacity(20%);
|
||||||
|
cursor: pointer;
|
||||||
opacity: 30%;
|
|
||||||
transition: opacity 0.25s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.colorChanger:hover {
|
.colorChanger:hover {
|
||||||
opacity: 100%;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,8 +179,8 @@ export default function ColorChanger() {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src="/paint.svg"
|
src="/paint.svg"
|
||||||
width="64"
|
width="32"
|
||||||
height="64"
|
height="32"
|
||||||
alt="paint"
|
alt="paint"
|
||||||
title={current}
|
title={current}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
.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;
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
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;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formRow label {
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
font-variant: all-small-caps;
|
font-variant: all-small-caps;
|
||||||
font-size: 105%;
|
font-size: 105%;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
|
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
|
||||||
import React from "react";
|
import React, { LabelHTMLAttributes } from "react";
|
||||||
import styles from "./Input.module.css";
|
import styles from "./Input.module.css";
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
type CustomInputProps<T> = {
|
type CustomInputProps<T> = {
|
||||||
customRender?: (fieldProps: FieldProps) => React.ReactNode;
|
customRender?: (fieldProps: FieldProps) => React.ReactNode;
|
||||||
|
@ -11,15 +12,37 @@ type CustomInputProps<T> = {
|
||||||
) => void;
|
) => 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>(
|
export default function Input<T>(
|
||||||
props: CustomInputProps<T> &
|
props: CustomInputProps<T> &
|
||||||
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }>
|
FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }>
|
||||||
) {
|
) {
|
||||||
const generatedId = React.useId();
|
const generatedId = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.formRow}>
|
<div className={classnames("form-row", styles.formRow)}>
|
||||||
<label htmlFor={generatedId}>{props.label}</label>
|
{props.label ? <Label htmlFor={generatedId}>{props.label}</Label> : null}
|
||||||
<Field id={generatedId} {...props}>
|
<Field id={generatedId} {...props}>
|
||||||
{(fieldProps: FieldProps) => {
|
{(fieldProps: FieldProps) => {
|
||||||
let { field, meta, form } = fieldProps;
|
let { field, meta, form } = fieldProps;
|
||||||
|
@ -51,7 +74,6 @@ export default function Input<T>(
|
||||||
title={props.title}
|
title={props.title}
|
||||||
{...inputFields}
|
{...inputFields}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
console.log(event);
|
|
||||||
if (props.customOnChange) {
|
if (props.customOnChange) {
|
||||||
console.log("using custom on change");
|
console.log("using custom on change");
|
||||||
props.customOnChange(event, form);
|
props.customOnChange(event, form);
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
.logo {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
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,7 +1,3 @@
|
||||||
.form {
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form :is(button, input)[type="submit"] {
|
.form :is(button, input)[type="submit"] {
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
|
@ -5,7 +5,7 @@ export default function PrettyForm({
|
||||||
globalError,
|
globalError,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
globalError: string | null;
|
globalError?: string | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -6,8 +6,8 @@ export default function CheckIcon() {
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
>
|
>
|
||||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||||
<g id="check" fill="currentColor" fill-rule="nonzero">
|
<g id="check" fill="currentColor" fillRule="nonzero">
|
||||||
<path
|
<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"
|
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"
|
id="Path-4"
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
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,12 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function UserIcon() {
|
export default function UserIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 128 128"
|
viewBox="0 0 128 128"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||||
<circle fill="currentColor" cx="64" cy="48" r="32"></circle>
|
<circle fill="currentColor" cx="64" cy="48" r="32"></circle>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
export async function ensureJpg(avatar: Buffer) {
|
export async function ensureJpg(avatar: Buffer) {
|
||||||
const img = await sharp(avatar).toFormat("jpeg").resize(512, 512);
|
const img = await sharp(avatar).toFormat("jpeg").resize(1024, 1024);
|
||||||
const buf = await img.toBuffer();
|
const buf = await img.toBuffer();
|
||||||
return buf.toString("base64");
|
return buf.toString("base64");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
const logger = pino();
|
if (global.logger == null) {
|
||||||
|
global.logger = pino();
|
||||||
|
}
|
||||||
|
|
||||||
export function getLogger(name: string) {
|
export function getLogger(name: string) {
|
||||||
return logger.child({ name });
|
return global.logger.child({ name });
|
||||||
}
|
}
|
||||||
|
|
118
src/schemas.ts
118
src/schemas.ts
|
@ -1,107 +1,71 @@
|
||||||
import * as Yup from "yup";
|
import { z } from "zod";
|
||||||
|
|
||||||
const REQUIRED = "Required.";
|
const USERNAME = z
|
||||||
const USERNAME = Yup.string()
|
.string()
|
||||||
.required(REQUIRED)
|
.min(1, "Username is too short.")
|
||||||
.min(1, "Username is too short.");
|
.regex(/^[a-z0-9]+$/, "Username must be lowercase alphanumeric.");
|
||||||
const DISPLAY_NAME = Yup.string()
|
const DISPLAY_NAME = z.string().min(1, "Display name is too short.");
|
||||||
.required(REQUIRED)
|
const EMAIL = z.string().email("Not an email.");
|
||||||
.min(1, "Display name is too short.");
|
const PASSWORD = z
|
||||||
const EMAIL = Yup.string().required(REQUIRED).email("Not an email.");
|
.string()
|
||||||
|
|
||||||
const PASSWORD = Yup.string()
|
|
||||||
.required(REQUIRED)
|
|
||||||
.min(12, "Password must be at least 12 characters long.");
|
.min(12, "Password must be at least 12 characters long.");
|
||||||
const CONFIRM_PASSWORD = (name: string) =>
|
const AVATAR = z.string().refine(
|
||||||
Yup.string()
|
(val) => {
|
||||||
.required(REQUIRED)
|
const parts = val.split(",");
|
||||||
.oneOf([Yup.ref(name, {})], "Passwords must match.");
|
const data = parts.length === 2 ? parts[1] : parts[0];
|
||||||
|
|
||||||
const AVATAR = Yup.string().test(
|
|
||||||
"file-size",
|
|
||||||
"File is bigger than 1 MB.",
|
|
||||||
(value) => {
|
|
||||||
if (value == null) return true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.from(value, "base64");
|
const buf = Buffer.from(data, "base64");
|
||||||
return buf.length <= 1_000_000;
|
return buf.length <= 2_000_000;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "File is bigger than 2 MB.",
|
||||||
|
path: ["avatar"]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const loginSchema = Yup.object().shape({
|
export const loginSchema = z.object({
|
||||||
username: USERNAME,
|
username: USERNAME,
|
||||||
password: PASSWORD
|
password: PASSWORD
|
||||||
});
|
});
|
||||||
|
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export type LoginFormValues = {
|
export const registerSchema = z
|
||||||
username: string;
|
.object({
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const registerSchema: Yup.Schema<RegisterFormValues> =
|
|
||||||
Yup.object().shape({
|
|
||||||
username: USERNAME,
|
username: USERNAME,
|
||||||
displayName: DISPLAY_NAME,
|
displayName: DISPLAY_NAME,
|
||||||
email: EMAIL,
|
email: EMAIL,
|
||||||
password: PASSWORD,
|
password: PASSWORD,
|
||||||
confirmPassword: CONFIRM_PASSWORD("password"),
|
confirmPassword: PASSWORD,
|
||||||
avatar: AVATAR
|
avatar: AVATAR.optional()
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords do not match.",
|
||||||
|
path: ["confirmPassword"]
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface RegisterFormValues {
|
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
confirmPassword: string;
|
|
||||||
avatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const aboutMeSchema: Yup.Schema<AboutMeFormValues> = Yup.object().shape({
|
export const aboutMeSchema = z.object({
|
||||||
username: USERNAME,
|
username: USERNAME,
|
||||||
displayName: DISPLAY_NAME,
|
displayName: DISPLAY_NAME,
|
||||||
email: EMAIL,
|
email: EMAIL,
|
||||||
avatar: AVATAR
|
avatar: AVATAR.optional()
|
||||||
});
|
});
|
||||||
|
export type AboutMeSchema = z.infer<typeof aboutMeSchema>;
|
||||||
|
|
||||||
export interface AboutMeFormValues {
|
export const passwordUpdateSchema = z
|
||||||
username: string;
|
.object({
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
avatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const passwordUpdateSchema: Yup.Schema<PasswordUpdateFormValues> =
|
|
||||||
Yup.object().shape({
|
|
||||||
password: PASSWORD,
|
password: PASSWORD,
|
||||||
newPassword: PASSWORD,
|
newPassword: PASSWORD,
|
||||||
confirmPassword: CONFIRM_PASSWORD("newPassword")
|
confirmPassword: PASSWORD
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: "Passwords do not match.",
|
||||||
|
path: ["confirmPassword"]
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface PasswordUpdateFormValues {
|
export type PasswordUpdateSchema = z.infer<typeof passwordUpdateSchema>;
|
||||||
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 New Issue