forked from NotNet/gluestick
glorbus
This commit is contained in:
parent
1363e9796c
commit
f9f28810a2
15
README.md
15
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/
|
||||
```
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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");
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -74,6 +74,11 @@ label {
|
|||
color var(--theme-transition);
|
||||
}
|
||||
|
||||
input:disabled, button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
"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<HTMLInputElement>(null);
|
||||
const passwordRef = React.useRef<HTMLInputElement>(null);
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameRef.current?.value ?? "";
|
||||
const password = passwordRef.current?.value ?? "";
|
||||
async function handleFormSubmit(
|
||||
{ username, password }: LoginFormValues,
|
||||
{ setSubmitting }: FormikHelpers<LoginFormValues>
|
||||
) {
|
||||
setSubmitting(true);
|
||||
|
||||
const req = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
|
@ -27,23 +26,52 @@ export default function LoginForm() {
|
|||
})
|
||||
});
|
||||
|
||||
if (req.status === 200) {
|
||||
const res: { ticket: string } = await req.json();
|
||||
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 {
|
||||
// todo error handling lol
|
||||
// only error is invalidCredentials, I am lazy
|
||||
setGlobalError("Invalid credentials.");
|
||||
}
|
||||
}}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGlobalError("shits fucked up yo");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PrettyForm globalError={globalError}>
|
||||
<Formik
|
||||
initialValues={{ username: "", password: "" }}
|
||||
onSubmit={handleFormSubmit}
|
||||
validationSchema={loginSchema}
|
||||
>
|
||||
<input type="text" placeholder="Username" ref={usernameRef} required />
|
||||
<input
|
||||
{() => (
|
||||
<Form>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="julian"
|
||||
name="username"
|
||||
label="Username"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
ref={passwordRef}
|
||||
required
|
||||
placeholder="deeznuts47"
|
||||
name="password"
|
||||
label="Password"
|
||||
/>
|
||||
<input type="submit" value="Login" />
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</PrettyForm>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -278,6 +278,21 @@ export default function AboutMe({ info }: { info: UserInfo }) {
|
|||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<input
|
||||
type="button"
|
||||
value="Log out"
|
||||
className={styles.fancyInput}
|
||||
onClick={async () => {
|
||||
document.cookie =
|
||||
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||
window.location.href = "/";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 <AboutMe info={info} />;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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=/;`
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
});
|
||||
}
|
|
@ -14,6 +14,7 @@ export default function Home() {
|
|||
>
|
||||
<a href="/login">login</a>
|
||||
<a href="/oauth/discord/login">register (discord)</a>
|
||||
<a href="/oauth/github/login">register (github)</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,94 +1,53 @@
|
|||
"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<HTMLInputElement>;
|
||||
export default function RegisterForm({
|
||||
initialDisplayName,
|
||||
initialEmail,
|
||||
initialAvatarBase64
|
||||
}: {
|
||||
initialDisplayName?: string;
|
||||
initialEmail?: string;
|
||||
initialAvatarBase64?: string;
|
||||
}) {
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
return (
|
||||
<div className={styles.formRow}>
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
<input {...props} ref={ref} />
|
||||
{props.error != null ? (
|
||||
<p className={styles.error}>{props.error}</p>
|
||||
) : (
|
||||
<p className={styles.hint}>{props.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
async function fileAsBase64(f: File) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(f);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result as ArrayBuffer;
|
||||
const buffer = Buffer.from(result);
|
||||
resolve(buffer.toString("base64"));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
const [initialValues, setInitialValues] = React.useState<RegisterFormValues>({
|
||||
username: "",
|
||||
displayName: initialDisplayName ?? "",
|
||||
email: initialEmail ?? "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
avatar: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export default function RegisterForm() {
|
||||
const usernameRef = React.useRef<HTMLInputElement>(null);
|
||||
const displayNameRef = React.useRef<HTMLInputElement>(null);
|
||||
const emailRef = React.useRef<HTMLInputElement>(null);
|
||||
const passwordRef = React.useRef<HTMLInputElement>(null);
|
||||
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
|
||||
const avatarRef = React.useRef<HTMLInputElement>(null);
|
||||
const submitRef = React.useRef<HTMLInputElement>(null);
|
||||
async function handleFormSubmit(
|
||||
{ avatar, username, displayName, email, password }: RegisterFormValues,
|
||||
{ setFieldError, setSubmitting }: FormikHelpers<RegisterFormValues>
|
||||
) {
|
||||
setSubmitting(true);
|
||||
|
||||
const [usernameTaken, setUsernameTaken] = React.useState(false);
|
||||
const [passwordMismatch, setPasswordMismatch] = React.useState(false);
|
||||
const [avatarBig, setAvatarBig] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.form}>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const [username, displayName, email, password, confirmPassword] = [
|
||||
usernameRef,
|
||||
displayNameRef,
|
||||
emailRef,
|
||||
passwordRef,
|
||||
confirmPasswordRef
|
||||
].map((ref) => ref.current?.value);
|
||||
const avatar = avatarRef.current?.files?.[0];
|
||||
|
||||
const avatarBase64 =
|
||||
avatar != null ? await fileAsBase64(avatar!) : null;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordMismatch(true);
|
||||
return;
|
||||
let avatarBase64 = avatar != null ? await fileAsBase64(avatar) : null;
|
||||
if (avatarBase64 == null && initialAvatarBase64 != null) {
|
||||
avatarBase64 = initialAvatarBase64.split(",")[1];
|
||||
}
|
||||
|
||||
if (avatar != null && avatar?.size > 1_000_000) {
|
||||
setAvatarBig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
submitRef.current!.disabled = true;
|
||||
const req = await fetch(`/api/register`, {
|
||||
const resp = await fetch(`/api/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
|
@ -101,55 +60,60 @@ export default function RegisterForm() {
|
|||
avatarBase64
|
||||
})
|
||||
});
|
||||
submitRef.current!.disabled = false;
|
||||
|
||||
if (req.status === 500) {
|
||||
// something real bad fucked up
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res: RegisterResponse = await req.json();
|
||||
const res: RegisterResponse = await resp.json();
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = "/me";
|
||||
router.replace("/me");
|
||||
} else {
|
||||
if (res.error !== null) {
|
||||
switch (res.error) {
|
||||
case "avatarBig":
|
||||
setAvatarBig(true);
|
||||
setFieldError(
|
||||
"avatar",
|
||||
"avatar was too big, but only the server caught you what the fuck are you doing!!"
|
||||
);
|
||||
break;
|
||||
|
||||
case "usernameTaken":
|
||||
setUsernameTaken(true);
|
||||
setFieldError("username", "Username is already taken.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error(req);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGlobalError("you done fucked up kiddo");
|
||||
}
|
||||
}}
|
||||
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PrettyForm globalError={globalError}>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleFormSubmit}
|
||||
validationSchema={registerSchema}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<Input
|
||||
hint="The username you'll use to log into NotNet services. By standard, this should be lowercase, and usually your first name."
|
||||
type="text"
|
||||
name="username"
|
||||
label="Username"
|
||||
placeholder="julian"
|
||||
ref={usernameRef}
|
||||
required
|
||||
error={usernameTaken ? "Username is taken." : undefined}
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your display name - this can be what you go by online, for example."
|
||||
type="text"
|
||||
name="display-name"
|
||||
name="displayName"
|
||||
label="Display name"
|
||||
placeholder="NotNite"
|
||||
ref={displayNameRef}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
|
@ -158,8 +122,6 @@ export default function RegisterForm() {
|
|||
name="email"
|
||||
label="Email"
|
||||
placeholder="hi@notnite.com"
|
||||
ref={emailRef}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
|
@ -169,20 +131,15 @@ export default function RegisterForm() {
|
|||
label="Password"
|
||||
placeholder="deeznuts47"
|
||||
minLength={12}
|
||||
ref={passwordRef}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
name="confirm-password"
|
||||
name="confirmPassword"
|
||||
label="Confirm password"
|
||||
placeholder="deeznuts47"
|
||||
minLength={12}
|
||||
ref={confirmPasswordRef}
|
||||
required
|
||||
error={passwordMismatch ? "Passwords do not match." : undefined}
|
||||
/>
|
||||
|
||||
<Input
|
||||
|
@ -194,14 +151,20 @@ export default function RegisterForm() {
|
|||
name="avatar"
|
||||
label="Avatar"
|
||||
accept="image/png, image/jpeg"
|
||||
ref={avatarRef}
|
||||
error={avatarBig ? "Avatar is too big." : undefined}
|
||||
customOnChange={(event, form) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (file != null) {
|
||||
form.setFieldValue("avatar", file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<input type="submit" value="Join NotNet!" ref={submitRef} />
|
||||
</div>
|
||||
</form>
|
||||
<HugeSubmit value="Join NotNet!" disabled={isSubmitting} />
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</PrettyForm>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 <p>fuck off</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className={styles.main}>
|
||||
<RegisterForm />
|
||||
<RegisterForm
|
||||
initialDisplayName={searchParams.displayName}
|
||||
initialEmail={searchParams.email}
|
||||
initialAvatarBase64={initialAvatarBase64}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
.hugeSubmit {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 140%;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hugeSubmit:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--fg-dark);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import React, { InputHTMLAttributes } from "react";
|
||||
import styles from "./HugeSubmit.module.css";
|
||||
|
||||
export default function HugeSubmit(
|
||||
props: InputHTMLAttributes<HTMLInputElement>
|
||||
) {
|
||||
return <input type="submit" className={styles.hugeSubmit} {...props} />;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
|
||||
import React from "react";
|
||||
import styles from "./Input.module.css";
|
||||
|
||||
type CustomInputProps<T> = {
|
||||
customOnChange?: (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
form: FormikProps<T>
|
||||
) => void;
|
||||
};
|
||||
|
||||
export default function Input<T>(
|
||||
props: CustomInputProps<T> & FieldAttributes<{ hint?: string; label: string }>
|
||||
) {
|
||||
const generatedId = React.useId();
|
||||
|
||||
return (
|
||||
<div className={styles.formRow}>
|
||||
<label htmlFor={generatedId}>{props.label}</label>
|
||||
<Field id={generatedId} {...props}>
|
||||
{({ field, meta, form }: FieldProps) => {
|
||||
let textAfterField =
|
||||
meta.touched && meta.error ? (
|
||||
<p className={styles.error}>{meta.error}</p>
|
||||
) : (
|
||||
props.hint && <p className={styles.hint}>{props.hint}</p>
|
||||
);
|
||||
|
||||
// <input type="file"> in React is always uncontrolled, so we have to hardcode
|
||||
// the value to "" if it's a file picker
|
||||
const inputFields =
|
||||
props.type === "file"
|
||||
? (() => {
|
||||
let clonedField = Object.assign({}, field);
|
||||
delete clonedField.value;
|
||||
return clonedField;
|
||||
})()
|
||||
: field;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type={props.type}
|
||||
placeholder={props.placeholder}
|
||||
{...inputFields}
|
||||
onChange={(event) => {
|
||||
console.log(event);
|
||||
if (props.customOnChange) {
|
||||
console.log("using custom on change");
|
||||
props.customOnChange(event, form);
|
||||
} else {
|
||||
form.setFieldValue(field.name, event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{textAfterField}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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 (
|
||||
<div className={styles.form}>
|
||||
{globalError && <p className={styles.error}>{globalError}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
export async function fileAsBase64(f: File) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(f);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result as ArrayBuffer;
|
||||
const buffer = Buffer.from(result);
|
||||
resolve(buffer.toString("base64"));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
});
|
||||
}
|
|
@ -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");
|
||||
}
|
65
src/ldap.ts
65
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<any> | 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({
|
||||
// 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: graphQLCache,
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "ignore"
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "all"
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authResponse!.token}`
|
||||
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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<RegisterFormValues> =
|
||||
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<File>()
|
||||
.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;
|
||||
}
|
Loading…
Reference in New Issue