From f9f28810a27071902825ac18ebce0add45bbe9e8 Mon Sep 17 00:00:00 2001
From: slice
Date: Wed, 26 Apr 2023 18:21:28 -0700
Subject: [PATCH] glorbus
---
README.md | 15 +-
environment.d.ts | 14 +
package-lock.json | 172 ++++++++-
package.json | 4 +-
.../20230426210117_add_github/migration.sql | 10 +
prisma/schema.prisma | 10 +
src/app/api/register/route.ts | 21 +-
src/app/globals.css | 5 +
src/app/login/LoginForm.tsx | 108 +++---
src/app/me/AboutMe.tsx | 15 +
src/app/me/page.tsx | 11 +-
src/app/oauth/discord/oauth.ts | 11 +-
src/app/oauth/discord/redirect/route.ts | 22 +-
src/app/oauth/github/login/route.ts | 22 ++
src/app/oauth/github/oauth.ts | 41 +++
src/app/oauth/github/redirect/route.ts | 153 ++++++++
src/app/page.tsx | 1 +
src/app/register/RegisterForm.module.css | 7 +-
src/app/register/RegisterForm.tsx | 329 ++++++++----------
src/app/register/page.tsx | 51 ++-
src/auth.ts | 4 +-
src/components/HugeSubmit.module.css | 10 +
src/components/HugeSubmit.tsx | 8 +
src/components/Input.module.css | 42 +++
src/components/Input.tsx | 63 ++++
src/components/PrettyForm.module.css | 17 +
src/components/PrettyForm.tsx | 17 +
src/forms.ts | 12 +
src/image.ts | 7 +
src/ldap.ts | 71 ++--
src/prisma.ts | 25 +-
src/schemas.ts | 47 +++
32 files changed, 1025 insertions(+), 320 deletions(-)
create mode 100644 prisma/migrations/20230426210117_add_github/migration.sql
create mode 100644 src/app/oauth/github/login/route.ts
create mode 100644 src/app/oauth/github/oauth.ts
create mode 100644 src/app/oauth/github/redirect/route.ts
create mode 100644 src/components/HugeSubmit.module.css
create mode 100644 src/components/HugeSubmit.tsx
create mode 100644 src/components/Input.module.css
create mode 100644 src/components/Input.tsx
create mode 100644 src/components/PrettyForm.module.css
create mode 100644 src/components/PrettyForm.tsx
create mode 100644 src/forms.ts
create mode 100644 src/image.ts
create mode 100644 src/schemas.ts
diff --git a/README.md b/README.md
index 1e3e6b2..4bca939 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,9 @@ You will need:
- Ports are assumed to not have been changed from the defaults
- A [Discord application](https://discord.com/developers/applications) for authentication
- Set the redirect URL to `(your domain)/oauth/discord/redirect`
+- Both a [GitHub](https://github.com/settings/developers) OAuth app and personal access token
+ - The OAuth app will be used for authentication, and the PAT will be used for inviting users automatically
+ - Set the redirect URL to `(your domain)/oauth/github/redirect`
### Cloning & config
@@ -36,6 +39,11 @@ After cloning, create an `.env.local` with the following contents (in `key=value
- `DISCORD_ALLOWED_GUILDS`: a comma separated list of guild IDs
- Users must be in one of these guilds to register with gluestick
- Enable "Advanced > Developer Mode" in your Discord client to copy IDs
+- `GITHUB_CLIENT_ID`: the client ID from your GitHub OAuth app
+- `GITHUB_CLIENT_SECRET`: the client secret from your GitHub OAuth app
+- `GITHUB_TOKEN`: a personal access token, with the ability to modify organization members
+- `GITHUB_ORG`: an organization name
+ - Users must be in this organization to register with gluestick
- `LDAP_HOST`: the IP address or hostname of your LLDAP server
- `LDAP_DC`: your LDAP dc
- `LDAP_BIND_USER`: the bind user of your LLDAP server
@@ -53,7 +61,12 @@ DISCORD_ALLOWED_GUILDS=986268106416611368,805978396974514206
LDAP_HOST=auth
LDAP_DC=dc=n2,dc=pm
LDAP_BIND_USER=admin
-LDAP_BIND_PASSWORD=redacted
+LDAP_BIND_PASSWORD=redactedd
+
+GITHUB_CLIENT_ID=2c946381e680acfa5e4a
+GITHUB_CLIENT_SECRET=redacted
+GITHUB_TOKEN=redacted
+GITHUB_ORG=n2pm
BASE_DOMAIN=https://gluestick.n2.pm/
```
diff --git a/environment.d.ts b/environment.d.ts
index c9d02e2..f5efa40 100644
--- a/environment.d.ts
+++ b/environment.d.ts
@@ -1,4 +1,13 @@
+import { PrismaClient } from "@prisma/client";
+import { Client as LDAPClient } from "ldapts";
+import { ApolloClient, InMemoryCache } from "@apollo/client";
+import { LLDAPAuthResponse } from "@/ldap";
+
declare global {
+ var prisma: PrismaClient | undefined;
+ var ldapClient: LDAPClient | undefined;
+ var authResponse: LLDAPAuthResponse | undefined;
+
namespace NodeJS {
interface ProcessEnv {
DISCORD_CLIENT_ID: string;
@@ -10,6 +19,11 @@ declare global {
LDAP_BIND_USER: string;
LDAP_BIND_PASSWORD: string;
+ GITHUB_CLIENT_ID: string;
+ GITHUB_CLIENT_SECRET: string;
+ GITHUB_TOKEN: string;
+ GITHUB_ORG: string;
+
BASE_DOMAIN: string;
}
}
diff --git a/package-lock.json b/package-lock.json
index 28cff83..888b8d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"dotenv": "^16.0.3",
"eslint": "8.39.0",
"eslint-config-next": "13.3.1",
+ "formik": "^2.2.9",
"graphql": "^16.6.0",
"ldapts": "^4.2.5",
"next": "13.3.1",
@@ -25,7 +26,8 @@
"react-dom": "18.2.0",
"sharp": "^0.32.0",
"typescript": "5.0.4",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.0",
+ "yup": "^1.1.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^3.3.1",
@@ -3464,6 +3466,14 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
+ "node_modules/deepmerge": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+ "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -4425,6 +4435,34 @@
"is-callable": "^1.1.3"
}
},
+ "node_modules/formik": {
+ "version": "2.2.9",
+ "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
+ "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://opencollective.com/formik"
+ }
+ ],
+ "dependencies": {
+ "deepmerge": "^2.1.1",
+ "hoist-non-react-statics": "^3.3.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "react-fast-compare": "^2.0.1",
+ "tiny-warning": "^1.0.2",
+ "tslib": "^1.10.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/formik/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+ },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -5716,8 +5754,12 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -6757,6 +6799,11 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/property-expr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
+ "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
+ },
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@@ -6861,6 +6908,11 @@
"react": "^18.2.0"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
+ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7649,6 +7701,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
+ "node_modules/tiny-case": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
+ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
+ },
"node_modules/tiny-glob": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -7658,6 +7715,11 @@
"globrex": "^0.1.2"
}
},
+ "node_modules/tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
+ },
"node_modules/title-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
@@ -7699,6 +7761,11 @@
"node": ">=8.0"
}
},
+ "node_modules/toposort": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
+ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -8186,6 +8253,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/yup": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/yup/-/yup-1.1.1.tgz",
+ "integrity": "sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag==",
+ "dependencies": {
+ "property-expr": "^2.0.5",
+ "tiny-case": "^1.0.3",
+ "toposort": "^2.0.2",
+ "type-fest": "^2.19.0"
+ }
+ },
+ "node_modules/yup/node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/zen-observable": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
@@ -10717,6 +10806,11 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
+ "deepmerge": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+ "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
+ },
"defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -11460,6 +11554,27 @@
"is-callable": "^1.1.3"
}
},
+ "formik": {
+ "version": "2.2.9",
+ "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
+ "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
+ "requires": {
+ "deepmerge": "^2.1.1",
+ "hoist-non-react-statics": "^3.3.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "react-fast-compare": "^2.0.1",
+ "tiny-warning": "^1.0.2",
+ "tslib": "^1.10.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+ }
+ }
+ },
"fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -12371,8 +12486,12 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"lodash.merge": {
"version": "4.6.2",
@@ -13100,6 +13219,11 @@
"react-is": "^16.13.1"
}
},
+ "property-expr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
+ "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
+ },
"pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@@ -13174,6 +13298,11 @@
"scheduler": "^0.23.0"
}
},
+ "react-fast-compare": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
+ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
+ },
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -13749,6 +13878,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
+ "tiny-case": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
+ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
+ },
"tiny-glob": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -13758,6 +13892,11 @@
"globrex": "^0.1.2"
}
},
+ "tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
+ },
"title-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
@@ -13790,6 +13929,11 @@
"is-number": "^7.0.0"
}
},
+ "toposort": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
+ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
+ },
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -14138,6 +14282,24 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
+ "yup": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/yup/-/yup-1.1.1.tgz",
+ "integrity": "sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag==",
+ "requires": {
+ "property-expr": "^2.0.5",
+ "tiny-case": "^1.0.3",
+ "toposort": "^2.0.2",
+ "type-fest": "^2.19.0"
+ },
+ "dependencies": {
+ "type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="
+ }
+ }
+ },
"zen-observable": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
diff --git a/package.json b/package.json
index 60db58b..a837096 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dotenv": "^16.0.3",
"eslint": "8.39.0",
"eslint-config-next": "13.3.1",
+ "formik": "^2.2.9",
"graphql": "^16.6.0",
"ldapts": "^4.2.5",
"next": "13.3.1",
@@ -29,7 +30,8 @@
"react-dom": "18.2.0",
"sharp": "^0.32.0",
"typescript": "5.0.4",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.0",
+ "yup": "^1.1.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^3.3.1",
diff --git a/prisma/migrations/20230426210117_add_github/migration.sql b/prisma/migrations/20230426210117_add_github/migration.sql
new file mode 100644
index 0000000..a8a7d32
--- /dev/null
+++ b/prisma/migrations/20230426210117_add_github/migration.sql
@@ -0,0 +1,10 @@
+-- CreateTable
+CREATE TABLE "GitHubAuth" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "userId" INTEGER NOT NULL,
+ "accessToken" TEXT NOT NULL,
+ CONSTRAINT "GitHubAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "GitHubAuth_userId_key" ON "GitHubAuth"("userId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 39d297e..6699e0c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -22,6 +22,7 @@ model User {
authTicket AuthTicket?
discordAuth DiscordAuth?
+ githubAuth GitHubAuth?
}
model DiscordAuth {
@@ -34,3 +35,12 @@ model DiscordAuth {
refreshToken String
expiresAt DateTime
}
+
+model GitHubAuth {
+ id Int @id
+
+ user User @relation(fields: [userId], references: [id])
+ userId Int @unique
+
+ accessToken String
+}
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index ea49f13..52b03ef 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -2,6 +2,7 @@ import * as ldap from "@/ldap";
import prisma from "@/prisma";
import { getUser } from "@/auth";
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
+import { getGitHubAvatar } from "@/app/oauth/github/oauth";
import { getLogger } from "@/logger";
type RequestBody = {
@@ -64,6 +65,16 @@ export async function POST(request: Request) {
);
}
+ if (username.length < 1) {
+ return new Response(
+ JSON.stringify({
+ ok: false,
+ error: "usernameShort"
+ }),
+ { status: 400 }
+ );
+ }
+
if (password.length < 12) {
return new Response(
JSON.stringify({
@@ -89,16 +100,6 @@ export async function POST(request: Request) {
}
}
- const discordAuth = await prisma.discordAuth.findFirst({
- where: {
- userId: user.id
- }
- });
-
- if (discordAuth !== null && avatarBuf === undefined) {
- avatarBuf = await getDiscordAvatar(discordAuth.accessToken);
- }
-
const users = await ldap.getUsers();
for (const user of users) {
if (user.id.toLowerCase() === username.toLowerCase()) {
diff --git a/src/app/globals.css b/src/app/globals.css
index ae33bf5..522ebeb 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -74,6 +74,11 @@ label {
color var(--theme-transition);
}
+input:disabled, button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed !important;
+}
+
input,
button {
font: inherit;
diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx
index 6fa3ee6..dd04a61 100644
--- a/src/app/login/LoginForm.tsx
+++ b/src/app/login/LoginForm.tsx
@@ -1,49 +1,77 @@
"use client";
+import Input from "@/components/Input";
+import PrettyForm from "@/components/PrettyForm";
+import { LoginFormValues, loginSchema } from "@/schemas";
+import { Form, Formik, FormikHelpers, FormikValues } from "formik";
import React from "react";
-// TODO: use input from register & un programmer art this
export default function LoginForm() {
- const usernameRef = React.useRef(null);
- const passwordRef = React.useRef(null);
+ const [globalError, setGlobalError] = React.useState(null);
+
+ async function handleFormSubmit(
+ { username, password }: LoginFormValues,
+ { setSubmitting }: FormikHelpers
+ ) {
+ setSubmitting(true);
+
+ const req = await fetch("/api/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ username,
+ password
+ })
+ });
+
+ try {
+ const res: {
+ ok: boolean;
+ error?: string;
+ ticket: string;
+ } = await req.json();
+
+ if (res.ok) {
+ document.cookie = `ticket=${res.ticket}; path=/;`;
+ window.location.href = "/me";
+ } else {
+ // only error is invalidCredentials, I am lazy
+ setGlobalError("Invalid credentials.");
+ }
+ } catch (err) {
+ console.error(err);
+ setGlobalError("shits fucked up yo");
+ setSubmitting(false);
+ }
+ }
return (
-
+
+
+ {() => (
+
+ )}
+
+
);
}
diff --git a/src/app/me/AboutMe.tsx b/src/app/me/AboutMe.tsx
index 5c44186..df3a668 100644
--- a/src/app/me/AboutMe.tsx
+++ b/src/app/me/AboutMe.tsx
@@ -278,6 +278,21 @@ export default function AboutMe({ info }: { info: UserInfo }) {
/>
+
+
+
+
+ {
+ document.cookie =
+ "ticket=; expires=" + new Date().toUTCString() + "; path=/";
+ window.location.href = "/";
+ }}
+ />
+
);
}
diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx
index 046c7e1..70f611b 100644
--- a/src/app/me/page.tsx
+++ b/src/app/me/page.tsx
@@ -1,19 +1,14 @@
import { getUser } from "@/auth";
import { getUserInfo } from "@/ldap";
import AboutMe from "./AboutMe";
+import { redirect } from "next/navigation";
export default async function Page() {
const user = await getUser();
- if (!user) {
- window.location.href = "/login";
- return;
- }
+ if (!user) redirect("/login");
const info = await getUserInfo(user);
- if (info === null) {
- window.location.href = "/login";
- return;
- }
+ if (info === null) redirect("/login");
return ;
}
diff --git a/src/app/oauth/discord/oauth.ts b/src/app/oauth/discord/oauth.ts
index ea8ef3d..9688726 100644
--- a/src/app/oauth/discord/oauth.ts
+++ b/src/app/oauth/discord/oauth.ts
@@ -11,6 +11,8 @@ export type DiscordAccessTokenResponse = {
export type DiscordUserResponse = {
id: string;
avatar: string | null;
+ username: string;
+ email: string | null;
};
export type DiscordGuildResponse = {
@@ -21,14 +23,14 @@ export function discordRedirectUri() {
return `${process.env.BASE_DOMAIN}oauth/discord/redirect`;
}
-export async function getDiscordID(token: string) {
+export async function getDiscordUser(token: string) {
const req = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${token}`
}
});
const res: DiscordUserResponse = await req.json();
- return res.id;
+ return res;
}
export async function getDiscordGuilds(token: string) {
@@ -51,8 +53,5 @@ export async function getDiscordAvatar(token: string) {
const res: DiscordUserResponse = await req.json();
if (res.avatar === null) return null;
const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`;
-
- const avatarReq = await fetch(file);
- const avatarBuffer = await avatarReq.arrayBuffer();
- return Buffer.from(avatarBuffer);
+ return file;
}
diff --git a/src/app/oauth/discord/redirect/route.ts b/src/app/oauth/discord/redirect/route.ts
index d699ca9..46dd533 100644
--- a/src/app/oauth/discord/redirect/route.ts
+++ b/src/app/oauth/discord/redirect/route.ts
@@ -2,8 +2,9 @@ import { URLSearchParams } from "url";
import {
discordRedirectUri,
DiscordAccessTokenResponse,
- getDiscordID,
- getDiscordGuilds
+ getDiscordGuilds,
+ getDiscordUser,
+ getDiscordAvatar
} from "../oauth";
import { cookies } from "next/dist/client/components/headers";
import prisma from "@/prisma";
@@ -54,14 +55,14 @@ export async function GET(request: Request) {
let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json();
- const id = await getDiscordID(tokenBody.access_token);
+ const discordUser = await getDiscordUser(tokenBody.access_token);
const guilds = await getDiscordGuilds(tokenBody.access_token);
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
let allowed = false;
for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true;
if (!allowed) {
- logger.info(`user ${id} tried to sign up`);
+ logger.info({ id: discordUser.id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
@@ -72,10 +73,10 @@ export async function GET(request: Request) {
const discordAuth = await prisma.discordAuth.upsert({
where: {
- id
+ id: discordUser.id
},
create: {
- id,
+ id: discordUser.id,
accessToken: tokenBody.access_token,
refreshToken: tokenBody.refresh_token,
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000),
@@ -157,11 +158,18 @@ export async function GET(request: Request) {
});
}
+ const avatarUrl = await getDiscordAvatar(tokenBody.access_token);
+
+ const query = new URLSearchParams();
+ query.append("username", discordUser.username);
+ query.append("email", discordUser.email ?? "");
+ query.append("avatar", avatarUrl ?? "");
+
return new Response(null, {
status: 302,
headers: {
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
- Location: "/register"
+ Location: "/register?" + query.toString()
}
});
}
diff --git a/src/app/oauth/github/login/route.ts b/src/app/oauth/github/login/route.ts
new file mode 100644
index 0000000..af39915
--- /dev/null
+++ b/src/app/oauth/github/login/route.ts
@@ -0,0 +1,22 @@
+import { v4 } from "uuid";
+
+export async function GET(request: Request) {
+ let url = `https://github.com/login/oauth/authorize`;
+ let state = v4();
+
+ let params = new URLSearchParams();
+ params.set("client_id", process.env.GITHUB_CLIENT_ID);
+ params.set("scope", "user");
+ params.set("state", state);
+ params.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`);
+
+ url += `?${params.toString()}`;
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: url,
+ "Set-Cookie": `state=${state}; Path=/;`
+ }
+ });
+}
diff --git a/src/app/oauth/github/oauth.ts b/src/app/oauth/github/oauth.ts
new file mode 100644
index 0000000..4cc4d15
--- /dev/null
+++ b/src/app/oauth/github/oauth.ts
@@ -0,0 +1,41 @@
+export type GitHubAccessTokenResponse = {
+ access_token: string;
+ scope: string;
+ token_type: string;
+};
+
+export type GitHubUserResponse = {
+ login: string;
+ id: number;
+ avatar_url: string;
+ email: string;
+};
+
+export async function getGitHubUser(token: string) {
+ const req = await fetch("https://api.github.com/user", {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+ const res: GitHubUserResponse = await req.json();
+ return res;
+}
+
+export async function checkInOrg(username: string) {
+ const req = await fetch(
+ `https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`,
+ {
+ headers: {
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
+ }
+ }
+ );
+
+ const res: GitHubUserResponse[] = await req.json();
+ return res.some((user) => user.login === username);
+}
+
+export async function getGitHubAvatar(token: string) {
+ const user = await getGitHubUser(token);
+ return user.avatar_url;
+}
diff --git a/src/app/oauth/github/redirect/route.ts b/src/app/oauth/github/redirect/route.ts
new file mode 100644
index 0000000..e52d184
--- /dev/null
+++ b/src/app/oauth/github/redirect/route.ts
@@ -0,0 +1,153 @@
+import { getLogger } from "@/logger";
+import { cookies } from "next/dist/client/components/headers";
+import {
+ checkInOrg,
+ getGitHubAvatar,
+ getGitHubUser,
+ GitHubAccessTokenResponse
+} from "../oauth";
+import prisma from "@/prisma";
+import * as ldap from "@/ldap";
+import { v4 } from "uuid";
+
+const logger = getLogger("/oauth/github/redirect");
+
+export async function GET(request: Request) {
+ let url = new URL(request.url);
+ let code = url.searchParams.get("code");
+ let state = url.searchParams.get("state");
+
+ if (code === null || state === null) {
+ logger.info("request made with missing code/state");
+ return new Response("missing code/state", { status: 400 });
+ }
+
+ const cookieStore = cookies();
+ let cookieState = cookieStore.get("state");
+ // prevent forgery
+ if (cookieState?.value !== state) {
+ logger.info(
+ "request made with invalid state - someone attempting forgery?"
+ );
+ return new Response("state is invalid", { status: 400 });
+ }
+
+ let query = new URLSearchParams();
+ query.set("client_id", process.env.GITHUB_CLIENT_ID);
+ query.set("client_secret", process.env.GITHUB_CLIENT_SECRET);
+ query.set("code", code);
+ query.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`);
+
+ let tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`;
+ let tokenResponse = await fetch(tokenUrl, {
+ method: "POST",
+ headers: {
+ Accept: "application/json"
+ }
+ });
+
+ if (!tokenResponse.ok) {
+ logger.error("baby");
+ throw "baby";
+ }
+
+ let resp: GitHubAccessTokenResponse = await tokenResponse.json();
+ let accessToken = resp.access_token;
+ const githubUser = await getGitHubUser(accessToken);
+ const inOrg = await checkInOrg(githubUser.login);
+
+ if (!inOrg) {
+ logger.info({ id: githubUser.login }, "user tried to sign up");
+ return new Response("not permitted to register account", { status: 403 });
+ }
+
+ const githubAuth = await prisma.gitHubAuth.upsert({
+ where: { id: githubUser.id },
+ create: {
+ id: githubUser.id,
+ accessToken,
+ user: { create: { username: null } }
+ },
+ update: { accessToken }
+ });
+
+ const user = await prisma.user.findFirst({
+ where: {
+ id: githubAuth.userId
+ }
+ });
+
+ // check if user got deleted from ldap, same as /api/register
+ if (
+ user !== null &&
+ user.username !== null &&
+ !(await ldap.checkUserExists(user.username))
+ ) {
+ logger.warn(
+ { username: user.username },
+ "user doesn't exist in ldap anymore"
+ );
+ user.username = null;
+ await prisma.user.update({
+ where: {
+ id: user.id
+ },
+ data: {
+ username: null
+ }
+ });
+ }
+
+ const authTicket = await prisma.authTicket.upsert({
+ where: {
+ userId: user!.id
+ },
+ create: {
+ userId: user!.id,
+ ticket: v4(),
+ expiresAt: new Date(Date.now() + 86400000)
+ },
+ update: {
+ ticket: v4(),
+ expiresAt: new Date(Date.now() + 86400000)
+ }
+ });
+
+ await prisma.user.update({
+ where: {
+ id: user!.id
+ },
+ data: {
+ authTicket: {
+ connect: {
+ id: authTicket.id
+ }
+ }
+ }
+ });
+
+ if (user?.username !== null) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
+ Location: "/me"
+ }
+ });
+ }
+
+ const avatarUrl = await getGitHubAvatar(accessToken);
+
+ const query2 = new URLSearchParams();
+ query2.append("username", githubUser.login);
+ query2.append("email", githubUser.email);
+ query2.append("avatar", avatarUrl);
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
+ Location: "/register?" + query2.toString()
+ }
+ });
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 8e00a1e..f6a80df 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -14,6 +14,7 @@ export default function Home() {
>
login
register (discord)
+ register (github)
);
diff --git a/src/app/register/RegisterForm.module.css b/src/app/register/RegisterForm.module.css
index dc23419..f3b0ba8 100644
--- a/src/app/register/RegisterForm.module.css
+++ b/src/app/register/RegisterForm.module.css
@@ -2,7 +2,7 @@
max-width: 500px;
}
-.form input[type="submit"] {
+.form *[type="submit"] {
padding: 1rem 1.5rem;
font-size: 140%;
background: var(--bg-dark);
@@ -18,11 +18,6 @@
margin: 2rem 0;
}
-.buttonContainer input:disabled {
- cursor: not-allowed;
- color: var(--fg-dark);
-}
-
.formRow {
margin: 1rem 0;
}
diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx
index 6da3ad1..0367110 100644
--- a/src/app/register/RegisterForm.tsx
+++ b/src/app/register/RegisterForm.tsx
@@ -1,207 +1,170 @@
"use client";
-import React, { InputHTMLAttributes } from "react";
-import { HTMLInputTypeAttribute } from "react";
+import React from "react";
import styles from "./RegisterForm.module.css";
+import { Form, Formik, FormikHelpers, yupToFormErrors } from "formik";
+import { RegisterFormValues, registerSchema } from "@/schemas";
+import { useRouter } from "next/navigation";
+import { fileAsBase64 } from "@/forms";
+import Input from "@/components/Input";
+import PrettyForm from "@/components/PrettyForm";
+import HugeSubmit from "@/components/HugeSubmit";
type RegisterResponse = {
ok: boolean;
error?: string;
};
-type InputProps = {
- label: string;
- name: string;
- hint?: string;
- type: HTMLInputTypeAttribute;
- placeholder?: string;
- error?: string;
-} & InputHTMLAttributes;
+export default function RegisterForm({
+ initialDisplayName,
+ initialEmail,
+ initialAvatarBase64
+}: {
+ initialDisplayName?: string;
+ initialEmail?: string;
+ initialAvatarBase64?: string;
+}) {
+ const [globalError, setGlobalError] = React.useState(null);
+ const router = useRouter();
-const Input = React.forwardRef((props, ref) => {
- return (
-
-
-
- {props.error != null ? (
-
{props.error}
- ) : (
-
{props.hint}
- )}
-
- );
-});
-Input.displayName = "Input";
-
-async function fileAsBase64(f: File) {
- const reader = new FileReader();
- reader.readAsArrayBuffer(f);
- return new Promise((resolve, reject) => {
- reader.onload = () => {
- const result = reader.result as ArrayBuffer;
- const buffer = Buffer.from(result);
- resolve(buffer.toString("base64"));
- };
- reader.onerror = () => reject(reader.error);
+ const [initialValues, setInitialValues] = React.useState({
+ username: "",
+ displayName: initialDisplayName ?? "",
+ email: initialEmail ?? "",
+ password: "",
+ confirmPassword: "",
+ avatar: undefined
});
-}
-export default function RegisterForm() {
- const usernameRef = React.useRef(null);
- const displayNameRef = React.useRef(null);
- const emailRef = React.useRef(null);
- const passwordRef = React.useRef(null);
- const confirmPasswordRef = React.useRef(null);
- const avatarRef = React.useRef(null);
- const submitRef = React.useRef(null);
+ async function handleFormSubmit(
+ { avatar, username, displayName, email, password }: RegisterFormValues,
+ { setFieldError, setSubmitting }: FormikHelpers
+ ) {
+ setSubmitting(true);
- const [usernameTaken, setUsernameTaken] = React.useState(false);
- const [passwordMismatch, setPasswordMismatch] = React.useState(false);
- const [avatarBig, setAvatarBig] = React.useState(false);
+ let avatarBase64 = avatar != null ? await fileAsBase64(avatar) : null;
+ if (avatarBase64 == null && initialAvatarBase64 != null) {
+ avatarBase64 = initialAvatarBase64.split(",")[1];
+ }
+
+ const resp = await fetch(`/api/register`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ username,
+ displayName,
+ email,
+ password,
+ avatarBase64
+ })
+ });
+
+ try {
+ const res: RegisterResponse = await resp.json();
+
+ if (res.ok) {
+ router.replace("/me");
+ } else {
+ if (res.error !== null) {
+ switch (res.error) {
+ case "avatarBig":
+ setFieldError(
+ "avatar",
+ "avatar was too big, but only the server caught you what the fuck are you doing!!"
+ );
+ break;
+
+ case "usernameTaken":
+ setFieldError("username", "Username is already taken.");
+ break;
+ }
+ }
+ }
+ } catch (err) {
+ console.error(err);
+ setGlobalError("you done fucked up kiddo");
+ }
+
+ setSubmitting(false);
+ }
return (
-
+
+
+
+
+ )}
+
+
);
}
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 85353b6..4c76191 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -1,18 +1,61 @@
import { cookies } from "next/dist/client/components/headers";
import styles from "@/app/page.module.css";
import RegisterForm from "./RegisterForm";
+import { redirect, useRouter } from "next/navigation";
+import { ensureJpg } from "@/image";
-export default function Page() {
+function avatarUrlAllowed(url: URL): boolean {
+ let notGithub = url.hostname !== "avatars.githubusercontent.com";
+ let notDiscord = url.hostname !== "cdn.discordapp.com";
+
+ if (!notDiscord && !url.pathname.startsWith("/avatars")) return false;
+ return !(notGithub && notDiscord);
+}
+
+export default async function Page({
+ searchParams
+}: {
+ searchParams: {
+ displayName?: string;
+ email?: string;
+ avatar?: string;
+ };
+}) {
const cookieStore = cookies();
const ticket = cookieStore.get("ticket");
+
if (ticket === null) {
- window.location.href = "/";
- return;
+ redirect("/");
+ }
+
+ let initialAvatarBase64 = undefined;
+ if (searchParams.avatar != null && searchParams.avatar !== "") {
+ const url = new URL(searchParams.avatar);
+
+ if (avatarUrlAllowed(url)) {
+ return fuck off
;
+ }
+
+ const req = await fetch(searchParams.avatar);
+ const blob = await req.blob();
+ const arrayBuffer = await blob.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ try {
+ initialAvatarBase64 =
+ "data:image/jpeg;base64," + (await ensureJpg(buffer));
+ } catch (e) {
+ console.error(e);
+ }
}
return (
-
+
);
}
diff --git a/src/auth.ts b/src/auth.ts
index acaefd7..f32e47e 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -9,14 +9,14 @@ const logger = getLogger("auth.ts");
export async function getUser() {
const cookieStore = cookies();
const cookieTicket = cookieStore.get("ticket");
- if (cookieTicket === null) return null;
+ if (cookieTicket == null) return null;
const ticket = await prisma.authTicket.findFirst({
where: {
ticket: cookieTicket?.value
}
});
- if (ticket === null) return null;
+ if (ticket == null) return null;
const user = await prisma.user.findFirst({
where: {
diff --git a/src/components/HugeSubmit.module.css b/src/components/HugeSubmit.module.css
new file mode 100644
index 0000000..5e88983
--- /dev/null
+++ b/src/components/HugeSubmit.module.css
@@ -0,0 +1,10 @@
+.hugeSubmit {
+ padding: 1rem 1.5rem;
+ font-size: 140%;
+ font-weight: 600;
+}
+
+.hugeSubmit:disabled {
+ cursor: not-allowed;
+ color: var(--fg-dark);
+}
diff --git a/src/components/HugeSubmit.tsx b/src/components/HugeSubmit.tsx
new file mode 100644
index 0000000..446df23
--- /dev/null
+++ b/src/components/HugeSubmit.tsx
@@ -0,0 +1,8 @@
+import React, { InputHTMLAttributes } from "react";
+import styles from "./HugeSubmit.module.css";
+
+export default function HugeSubmit(
+ props: InputHTMLAttributes
+) {
+ return ;
+}
diff --git a/src/components/Input.module.css b/src/components/Input.module.css
new file mode 100644
index 0000000..4bf0f8b
--- /dev/null
+++ b/src/components/Input.module.css
@@ -0,0 +1,42 @@
+.buttonContainer {
+ display: flex;
+ justify-content: center;
+ margin: 2rem 0;
+}
+
+.buttonContainer input:disabled {
+ cursor: not-allowed;
+ color: var(--fg-dark);
+}
+
+.formRow {
+ margin: 1rem 0;
+}
+
+.formRow label {
+ display: block;
+ font-variant: all-small-caps;
+ font-size: 105%;
+}
+
+.formRow input {
+ padding: 0.5em 1em;
+ border: none;
+ border-radius: 0.15rem;
+ margin: 0.5rem 0;
+ width: 250px;
+ display: block;
+ background: var(--bg-dark);
+}
+
+.hint {
+ color: var(--fg-dark);
+ font-size: 80%;
+ transition: color var(--theme-transition);
+}
+
+.error {
+ color: var(--error);
+ font-size: 80%;
+ transition: color var(--theme-transition);
+}
diff --git a/src/components/Input.tsx b/src/components/Input.tsx
new file mode 100644
index 0000000..c5c79c5
--- /dev/null
+++ b/src/components/Input.tsx
@@ -0,0 +1,63 @@
+import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
+import React from "react";
+import styles from "./Input.module.css";
+
+type CustomInputProps = {
+ customOnChange?: (
+ event: React.ChangeEvent,
+ form: FormikProps
+ ) => void;
+};
+
+export default function Input(
+ props: CustomInputProps & FieldAttributes<{ hint?: string; label: string }>
+) {
+ const generatedId = React.useId();
+
+ return (
+
+ );
+}
diff --git a/src/components/PrettyForm.module.css b/src/components/PrettyForm.module.css
new file mode 100644
index 0000000..763080b
--- /dev/null
+++ b/src/components/PrettyForm.module.css
@@ -0,0 +1,17 @@
+.form {
+ max-width: 500px;
+}
+
+.form :is(button, input)[type="submit"] {
+ background: var(--bg-dark);
+ border: 0;
+ border-radius: 0.15rem;
+ cursor: pointer;
+ padding: 0.5em 1em;
+}
+
+.error {
+ color: var(--error);
+ font-size: 80%;
+ transition: color var(--theme-transition);
+}
\ No newline at end of file
diff --git a/src/components/PrettyForm.tsx b/src/components/PrettyForm.tsx
new file mode 100644
index 0000000..b062d8e
--- /dev/null
+++ b/src/components/PrettyForm.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+import styles from "./PrettyForm.module.css";
+
+export default function PrettyForm({
+ globalError,
+ children
+}: {
+ globalError: string | null;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {globalError &&
{globalError}
}
+ {children}
+
+ );
+}
diff --git a/src/forms.ts b/src/forms.ts
new file mode 100644
index 0000000..8554088
--- /dev/null
+++ b/src/forms.ts
@@ -0,0 +1,12 @@
+export async function fileAsBase64(f: File) {
+ const reader = new FileReader();
+ reader.readAsArrayBuffer(f);
+ return new Promise((resolve, reject) => {
+ reader.onload = () => {
+ const result = reader.result as ArrayBuffer;
+ const buffer = Buffer.from(result);
+ resolve(buffer.toString("base64"));
+ };
+ reader.onerror = () => reject(reader.error);
+ });
+}
diff --git a/src/image.ts b/src/image.ts
new file mode 100644
index 0000000..82026c9
--- /dev/null
+++ b/src/image.ts
@@ -0,0 +1,7 @@
+import sharp from "sharp";
+
+export async function ensureJpg(avatar: Buffer) {
+ const img = await sharp(avatar).toFormat("jpeg").resize(512, 512);
+ const buf = await img.toBuffer();
+ return buf.toString("base64");
+}
diff --git a/src/ldap.ts b/src/ldap.ts
index 8a4d187..d6f9a37 100644
--- a/src/ldap.ts
+++ b/src/ldap.ts
@@ -1,11 +1,11 @@
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { Client } from "ldapts";
import { gql } from "./__generated__";
-import sharp from "sharp";
import { BerWriter } from "asn1";
import { User } from "@prisma/client";
+import { ensureJpg } from "@/image";
-type LLDAPAuthResponse = {
+export type LLDAPAuthResponse = {
token: string;
refreshToken: string;
};
@@ -21,31 +21,29 @@ export type UserInfo = {
avatar?: string;
};
-let ldapClient: Client | null = null;
async function getLdapClient() {
- if (ldapClient === null) {
- ldapClient = new Client({
+ if (global.ldapClient == null) {
+ global.ldapClient = new Client({
url: `ldap://${process.env.LDAP_HOST}:3890`
});
const full = `uid=${process.env.LDAP_BIND_USER},ou=people,${process.env.LDAP_DC}`;
- await ldapClient.bind(full, process.env.LDAP_BIND_PASSWORD);
+ await global.ldapClient.bind(full, process.env.LDAP_BIND_PASSWORD);
}
- return ldapClient;
+ return global.ldapClient;
}
-let authResponse: LLDAPAuthResponse | null = null;
async function regenAuthToken() {
- if (authResponse !== null) {
+ if (global.authResponse != null) {
const url = `http://${process.env.LDAP_HOST}:17170/auth/refresh`;
const req = await fetch(url, {
headers: {
- "Refresh-Token": authResponse.refreshToken
+ "Refresh-Token": global.authResponse.refreshToken
}
});
const res: LLDAPRefreshResponse = await req.json();
- authResponse.token = res.token;
+ global.authResponse.token = res.token;
} else {
const url = `http://${process.env.LDAP_HOST}:17170/auth/simple/login`;
const req = await fetch(url, {
@@ -59,7 +57,7 @@ async function regenAuthToken() {
})
});
- authResponse = await req.json();
+ global.authResponse = await req.json();
}
// valid for one day, so refresh every 12 hours
@@ -67,31 +65,35 @@ async function regenAuthToken() {
}
async function getAuthToken() {
- if (authResponse === null) await regenAuthToken();
- return authResponse!.token;
+ if (global.authResponse == null) await regenAuthToken();
+ return global.authResponse!.token;
}
-let graphQLClient: ApolloClient | null = null;
-let graphQLCache = new InMemoryCache();
-let graphQLAuthToken: string | null = null;
async function getGraphQLClient() {
- if (authResponse === null) {
+ if (global.authResponse == null) {
await getAuthToken();
- graphQLAuthToken = authResponse!.token;
}
- // We keep track of the auth token we used in the client, so we can
- // recreate it when it expires/refreshes
- if (graphQLClient === null || graphQLAuthToken !== authResponse!.token) {
- graphQLClient = new ApolloClient({
- uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`,
- cache: graphQLCache,
- headers: {
- Authorization: `Bearer ${authResponse!.token}`
+ // Remake the client every time because Apollo caching is fucking stupid
+ let graphQLClient = new ApolloClient({
+ uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`,
+ cache: new InMemoryCache(),
+ defaultOptions: {
+ watchQuery: {
+ fetchPolicy: "no-cache",
+ errorPolicy: "ignore"
+ },
+ query: {
+ fetchPolicy: "no-cache",
+ errorPolicy: "all"
}
- });
- }
+ },
+ headers: {
+ Authorization: `Bearer ${global.authResponse!.token}`
+ }
+ });
+ // whoever designed to cache this shit is FUCKING STUPID
return graphQLClient;
}
@@ -110,12 +112,6 @@ export async function getUsers() {
return query.data.users;
}
-async function ensureJpg(avatar: Buffer) {
- const img = await sharp(avatar).toFormat("jpeg").resize(512, 512);
- const buf = await img.toBuffer();
- return buf.toString("base64");
-}
-
export async function createUser(
username: string,
displayName: string,
@@ -176,9 +172,8 @@ export async function validateUser(username: string, password: string) {
}
export async function checkUserExists(username: string) {
- return (await getUsers()).find(
- (u) => u.id.toLowerCase() === username.toLowerCase()
- );
+ const users = await getUsers();
+ return users.find((u) => u.id.toLowerCase() === username.toLowerCase());
}
export async function getUserInfo(user: User) {
diff --git a/src/prisma.ts b/src/prisma.ts
index 319b2f4..3ce0363 100644
--- a/src/prisma.ts
+++ b/src/prisma.ts
@@ -1,9 +1,8 @@
-import { PrismaClient } from "@prisma/client";
+import { Prisma, PrismaClient } from "@prisma/client";
import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth";
-const prisma = new PrismaClient();
-// refresh 6 hours before expiry
-async function refreshDiscordTokens() {
+async function refreshDiscordTokens(prisma: PrismaClient) {
+ // refresh 6 hours before expiry
const refreshWindow = 6 * 60 * 60 * 1000;
const discordAuths = await prisma.discordAuth.findMany({
@@ -43,7 +42,7 @@ async function refreshDiscordTokens() {
}
}
-async function expireTickets() {
+async function expireTickets(prisma: PrismaClient) {
const expired = await prisma.authTicket.findMany({
where: {
expiresAt: {
@@ -61,9 +60,17 @@ async function expireTickets() {
}
}
-setInterval(async () => {
- await refreshDiscordTokens();
- await expireTickets();
-}, 60 * 1000);
+let prisma: PrismaClient;
+if (global.prisma == undefined) {
+ global.prisma = new PrismaClient();
+ prisma = global.prisma;
+
+ setInterval(async () => {
+ await refreshDiscordTokens(prisma);
+ await expireTickets(prisma);
+ }, 60 * 1000);
+} else {
+ prisma = global.prisma;
+}
export default prisma;
diff --git a/src/schemas.ts b/src/schemas.ts
new file mode 100644
index 0000000..0867682
--- /dev/null
+++ b/src/schemas.ts
@@ -0,0 +1,47 @@
+import * as Yup from "yup";
+
+const REQUIRED = "Required.";
+const USERNAME = Yup.string()
+ .required(REQUIRED)
+ .min(1, "Username is too short.");
+const PASSWORD = Yup.string()
+ .required(REQUIRED)
+ .min(12, "Password must be at least 12 characters long.");
+
+export const loginSchema = Yup.object().shape({
+ username: USERNAME,
+ password: PASSWORD
+});
+
+export type LoginFormValues = {
+ username: string;
+ password: string;
+};
+
+export const registerSchema: Yup.Schema =
+ Yup.object().shape({
+ username: USERNAME,
+ displayName: Yup.string()
+ .required(REQUIRED)
+ .min(1, "Display name is too short."),
+ email: Yup.string().required(REQUIRED).email("Not an email."),
+ confirmPassword: Yup.string()
+ .required(REQUIRED)
+ .oneOf([Yup.ref("password", {})], "Passwords must match."),
+ password: PASSWORD,
+ avatar: Yup.mixed()
+ .test("fileSize", "File is larger than 1 MB.", (value) => {
+ if (value == null) return true;
+ return value.size <= 1_000_000;
+ })
+ .optional()
+ });
+
+export interface RegisterFormValues {
+ username: string;
+ displayName: string;
+ email: string;
+ password: string;
+ confirmPassword: string;
+ avatar?: File;
+}