Compare commits
3 Commits
1f984b8006
...
2acdeb8c95
Author | SHA1 | Date |
---|---|---|
Skip R. | 2acdeb8c95 | |
Skip R. | c0b8ee2427 | |
Skip R. | 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
|
- Ports are assumed to not have been changed from the defaults
|
||||||
- A [Discord application](https://discord.com/developers/applications) for authentication
|
- A [Discord application](https://discord.com/developers/applications) for authentication
|
||||||
- Set the redirect URL to `(your domain)/oauth/discord/redirect`
|
- 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
|
### 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
|
- `DISCORD_ALLOWED_GUILDS`: a comma separated list of guild IDs
|
||||||
- Users must be in one of these guilds to register with gluestick
|
- Users must be in one of these guilds to register with gluestick
|
||||||
- Enable "Advanced > Developer Mode" in your Discord client to copy IDs
|
- 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_HOST`: the IP address or hostname of your LLDAP server
|
||||||
- `LDAP_DC`: your LDAP dc
|
- `LDAP_DC`: your LDAP dc
|
||||||
- `LDAP_BIND_USER`: the bind user of your LLDAP server
|
- `LDAP_BIND_USER`: the bind user of your LLDAP server
|
||||||
|
@ -53,7 +61,12 @@ DISCORD_ALLOWED_GUILDS=986268106416611368,805978396974514206
|
||||||
LDAP_HOST=auth
|
LDAP_HOST=auth
|
||||||
LDAP_DC=dc=n2,dc=pm
|
LDAP_DC=dc=n2,dc=pm
|
||||||
LDAP_BIND_USER=admin
|
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/
|
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 {
|
declare global {
|
||||||
|
var prisma: PrismaClient | undefined;
|
||||||
|
var ldapClient: LDAPClient | undefined;
|
||||||
|
var authResponse: LLDAPAuthResponse | undefined;
|
||||||
|
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
DISCORD_CLIENT_ID: string;
|
DISCORD_CLIENT_ID: string;
|
||||||
|
@ -10,6 +19,11 @@ declare global {
|
||||||
LDAP_BIND_USER: string;
|
LDAP_BIND_USER: string;
|
||||||
LDAP_BIND_PASSWORD: string;
|
LDAP_BIND_PASSWORD: string;
|
||||||
|
|
||||||
|
GITHUB_CLIENT_ID: string;
|
||||||
|
GITHUB_CLIENT_SECRET: string;
|
||||||
|
GITHUB_TOKEN: string;
|
||||||
|
GITHUB_ORG: string;
|
||||||
|
|
||||||
BASE_DOMAIN: string;
|
BASE_DOMAIN: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,9 +14,11 @@
|
||||||
"@types/react": "18.0.38",
|
"@types/react": "18.0.38",
|
||||||
"@types/react-dom": "18.0.11",
|
"@types/react-dom": "18.0.11",
|
||||||
"asn1": "^0.2.6",
|
"asn1": "^0.2.6",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"eslint": "8.39.0",
|
"eslint": "8.39.0",
|
||||||
"eslint-config-next": "13.3.1",
|
"eslint-config-next": "13.3.1",
|
||||||
|
"formik": "^2.2.9",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"ldapts": "^4.2.5",
|
"ldapts": "^4.2.5",
|
||||||
"next": "13.3.1",
|
"next": "13.3.1",
|
||||||
|
@ -25,7 +27,8 @@
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"sharp": "^0.32.0",
|
"sharp": "^0.32.0",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0",
|
||||||
|
"yup": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^3.3.1",
|
"@graphql-codegen/cli": "^3.3.1",
|
||||||
|
@ -3136,6 +3139,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
|
||||||
|
},
|
||||||
"node_modules/clean-stack": {
|
"node_modules/clean-stack": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||||
|
@ -3464,6 +3472,14 @@
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
|
"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": {
|
"node_modules/defaults": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||||
|
@ -4425,6 +4441,34 @@
|
||||||
"is-callable": "^1.1.3"
|
"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": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
@ -5716,8 +5760,12 @@
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
"dev": true
|
},
|
||||||
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
|
@ -6757,6 +6805,11 @@
|
||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||||
|
@ -6861,6 +6914,11 @@
|
||||||
"react": "^18.2.0"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
@ -7649,6 +7707,11 @@
|
||||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/tiny-glob": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||||
|
@ -7658,6 +7721,11 @@
|
||||||
"globrex": "^0.1.2"
|
"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": {
|
"node_modules/title-case": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
|
||||||
|
@ -7699,6 +7767,11 @@
|
||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
@ -8186,6 +8259,28 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zen-observable": {
|
||||||
"version": "0.8.15",
|
"version": "0.8.15",
|
||||||
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
||||||
|
@ -10466,6 +10561,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
||||||
},
|
},
|
||||||
|
"classnames": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
|
||||||
|
},
|
||||||
"clean-stack": {
|
"clean-stack": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||||
|
@ -10717,6 +10817,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
|
"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": {
|
"defaults": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||||
|
@ -11460,6 +11565,27 @@
|
||||||
"is-callable": "^1.1.3"
|
"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": {
|
"fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
@ -12371,8 +12497,12 @@
|
||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
"dev": true
|
},
|
||||||
|
"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": {
|
"lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
|
@ -13100,6 +13230,11 @@
|
||||||
"react-is": "^16.13.1"
|
"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": {
|
"pump": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||||
|
@ -13174,6 +13309,11 @@
|
||||||
"scheduler": "^0.23.0"
|
"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": {
|
"react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
@ -13749,6 +13889,11 @@
|
||||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||||
"dev": true
|
"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": {
|
"tiny-glob": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||||
|
@ -13758,6 +13903,11 @@
|
||||||
"globrex": "^0.1.2"
|
"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": {
|
"title-case": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
|
||||||
|
@ -13790,6 +13940,11 @@
|
||||||
"is-number": "^7.0.0"
|
"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": {
|
"tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
@ -14138,6 +14293,24 @@
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
|
"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": {
|
"zen-observable": {
|
||||||
"version": "0.8.15",
|
"version": "0.8.15",
|
||||||
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
||||||
|
|
|
@ -18,9 +18,11 @@
|
||||||
"@types/react": "18.0.38",
|
"@types/react": "18.0.38",
|
||||||
"@types/react-dom": "18.0.11",
|
"@types/react-dom": "18.0.11",
|
||||||
"asn1": "^0.2.6",
|
"asn1": "^0.2.6",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"eslint": "8.39.0",
|
"eslint": "8.39.0",
|
||||||
"eslint-config-next": "13.3.1",
|
"eslint-config-next": "13.3.1",
|
||||||
|
"formik": "^2.2.9",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"ldapts": "^4.2.5",
|
"ldapts": "^4.2.5",
|
||||||
"next": "13.3.1",
|
"next": "13.3.1",
|
||||||
|
@ -29,7 +31,8 @@
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"sharp": "^0.32.0",
|
"sharp": "^0.32.0",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0",
|
||||||
|
"yup": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^3.3.1",
|
"@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?
|
authTicket AuthTicket?
|
||||||
|
|
||||||
discordAuth DiscordAuth?
|
discordAuth DiscordAuth?
|
||||||
|
githubAuth GitHubAuth?
|
||||||
}
|
}
|
||||||
|
|
||||||
model DiscordAuth {
|
model DiscordAuth {
|
||||||
|
@ -34,3 +35,12 @@ model DiscordAuth {
|
||||||
refreshToken String
|
refreshToken String
|
||||||
expiresAt DateTime
|
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 prisma from "@/prisma";
|
||||||
import { getUser } from "@/auth";
|
import { getUser } from "@/auth";
|
||||||
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
|
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
|
||||||
|
import { getGitHubAvatar } from "@/app/oauth/github/oauth";
|
||||||
import { getLogger } from "@/logger";
|
import { getLogger } from "@/logger";
|
||||||
|
|
||||||
type RequestBody = {
|
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) {
|
if (password.length < 12) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
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();
|
const users = await ldap.getUsers();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (user.id.toLowerCase() === username.toLowerCase()) {
|
if (user.id.toLowerCase() === username.toLowerCase()) {
|
||||||
|
|
|
@ -74,6 +74,11 @@ label {
|
||||||
color var(--theme-transition);
|
color var(--theme-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:disabled, button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import ColorChanger from "@/components/ColorChanger";
|
import ColorChanger from "@/components/ColorChanger";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "gluestick",
|
title: "gluestick",
|
||||||
|
@ -19,10 +16,11 @@ export default function RootLayout({
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" href="/icon.svg" />
|
<link rel="icon" href="/icon.svg" />
|
||||||
|
{/* todo: lmfao */}
|
||||||
<meta property="og:image" content="/icon.svg" />
|
<meta property="og:image" content="/icon.svg" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body className={inter.className}>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<ColorChanger />
|
<ColorChanger />
|
||||||
|
|
|
@ -1,49 +1,77 @@
|
||||||
"use client";
|
"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";
|
import React from "react";
|
||||||
|
|
||||||
// TODO: use input from register & un programmer art this
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const usernameRef = React.useRef<HTMLInputElement>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
const passwordRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
async function handleFormSubmit(
|
||||||
|
{ username, password }: LoginFormValues,
|
||||||
|
{ setSubmitting }: FormikHelpers<LoginFormValues>
|
||||||
|
) {
|
||||||
|
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 (
|
return (
|
||||||
<form
|
<PrettyForm globalError={globalError}>
|
||||||
style={{ display: "flex", flexDirection: "column" }}
|
<Formik
|
||||||
onSubmit={async (e) => {
|
initialValues={{ username: "", password: "" }}
|
||||||
e.preventDefault();
|
onSubmit={handleFormSubmit}
|
||||||
|
validationSchema={loginSchema}
|
||||||
const username = usernameRef.current?.value ?? "";
|
>
|
||||||
const password = passwordRef.current?.value ?? "";
|
{({ isSubmitting }) => (
|
||||||
|
<Form>
|
||||||
const req = await fetch("/api/login", {
|
<Input
|
||||||
method: "POST",
|
type="text"
|
||||||
headers: {
|
placeholder="julian"
|
||||||
"Content-Type": "application/json"
|
name="username"
|
||||||
},
|
label="Username"
|
||||||
body: JSON.stringify({
|
/>
|
||||||
username,
|
<Input
|
||||||
password
|
type="password"
|
||||||
})
|
placeholder="deeznuts47"
|
||||||
});
|
name="password"
|
||||||
|
label="Password"
|
||||||
if (req.status === 200) {
|
/>
|
||||||
const res: { ticket: string } = await req.json();
|
<input type="submit" value="Login" disabled={isSubmitting} />
|
||||||
document.cookie = `ticket=${res.ticket}; path=/;`;
|
</Form>
|
||||||
window.location.href = "/me";
|
)}
|
||||||
} else {
|
</Formik>
|
||||||
// todo error handling lol
|
</PrettyForm>
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="text" placeholder="Username" ref={usernameRef} required />
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Password"
|
|
||||||
ref={passwordRef}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<input type="submit" value="Login" />
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,103 +1,26 @@
|
||||||
.content {
|
.content {
|
||||||
max-width: 500px;
|
max-width: 700px;
|
||||||
margin: auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.profileGrid {
|
||||||
padding: 1rem;
|
/* todo */
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form input[type="submit"] {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
font-size: 140%;
|
|
||||||
background: var(--bg-dark);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonContainer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonContainer input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
color: var(--fg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.formRow {
|
|
||||||
margin: 1rem 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formRow label {
|
|
||||||
font-variant: all-small-caps;
|
|
||||||
font-size: 105%;
|
|
||||||
width: 100px;
|
|
||||||
height: 50px;
|
|
||||||
|
|
||||||
/* center */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formVert {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fancyInput {
|
|
||||||
padding: 0.5em 1em;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
width: 250px;
|
|
||||||
display: block;
|
|
||||||
background: var(--bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.formRow input[name="avatar"] {
|
|
||||||
width: 190px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formRow .avatar {
|
|
||||||
margin-right: 10px;
|
|
||||||
border-radius: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formRow input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
background: var(--bg-darker);
|
|
||||||
color: var(--fg-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
margin: auto;
|
|
||||||
|
|
||||||
background-color: var(--fg-darker);
|
background-color: var(--fg-darker);
|
||||||
height: 1px;
|
height: 1px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5em 1em;
|
||||||
}
|
}
|
|
@ -4,8 +4,16 @@
|
||||||
import { UserInfo } from "@/ldap";
|
import { UserInfo } from "@/ldap";
|
||||||
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
||||||
import styles from "./AboutMe.module.css";
|
import styles from "./AboutMe.module.css";
|
||||||
|
import AvatarChanger from "@/components/AvatarChanger";
|
||||||
const fallbackAvatar = "https://i.clong.biz/i/oc4zjlqr.png";
|
import Input from "@/components/Input";
|
||||||
|
import { Form, Formik, FormikHelpers } from "formik";
|
||||||
|
import {
|
||||||
|
AboutMeFormValues,
|
||||||
|
PasswordUpdateFormValues,
|
||||||
|
aboutMeSchema,
|
||||||
|
passwordUpdateSchema
|
||||||
|
} from "@/schemas";
|
||||||
|
import PrettyForm from "@/components/PrettyForm";
|
||||||
|
|
||||||
type UpdateResponse = {
|
type UpdateResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
@ -21,35 +29,6 @@ type InputProps = {
|
||||||
displayImage?: string;
|
displayImage?: string;
|
||||||
} & InputHTMLAttributes<HTMLInputElement>;
|
} & InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
|
||||||
// get console to shut up
|
|
||||||
const inputProps = { ...props };
|
|
||||||
delete inputProps.displayImage;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.formRow}>
|
|
||||||
<label htmlFor={props.id}>{props.label}</label>
|
|
||||||
|
|
||||||
{props.displayImage && (
|
|
||||||
<img
|
|
||||||
src={props.displayImage}
|
|
||||||
className={styles.avatar}
|
|
||||||
alt={"Your avatar"}
|
|
||||||
width="50px"
|
|
||||||
height="50px"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.formVert}>
|
|
||||||
<input {...inputProps} ref={ref} className={styles.fancyInput} />
|
|
||||||
|
|
||||||
{props.error != null && <p className={styles.error}>{props.error}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Input.displayName = "Input";
|
|
||||||
|
|
||||||
async function fileAsBase64(f: File) {
|
async function fileAsBase64(f: File) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsArrayBuffer(f);
|
reader.readAsArrayBuffer(f);
|
||||||
|
@ -64,220 +43,200 @@ async function fileAsBase64(f: File) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutMe({ info }: { info: UserInfo }) {
|
export default function AboutMe({ info }: { info: UserInfo }) {
|
||||||
const displayNameRef = React.useRef<HTMLInputElement>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
const emailRef = React.useRef<HTMLInputElement>(null);
|
const initialValues: AboutMeFormValues = {
|
||||||
const avatarRef = React.useRef<HTMLInputElement>(null);
|
username: info.username,
|
||||||
const submitRef = React.useRef<HTMLInputElement>(null);
|
displayName: info.displayName,
|
||||||
|
email: info.email,
|
||||||
|
avatar: info.avatar
|
||||||
|
};
|
||||||
|
|
||||||
const [avatar, setAvatar] = React.useState<string | null>(
|
async function handleFormSubmit(
|
||||||
info.avatar ?? null
|
{ displayName, email, avatar }: AboutMeFormValues,
|
||||||
);
|
{ setSubmitting }: FormikHelpers<AboutMeFormValues>
|
||||||
|
) {
|
||||||
|
setSubmitting(true);
|
||||||
|
const req = await fetch("/api/update", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
displayName,
|
||||||
|
email,
|
||||||
|
avatar: avatar != null ? avatar.split(",")[1] : null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
const currentPasswordRef = React.useRef<HTMLInputElement>(null);
|
try {
|
||||||
const newPasswordRef = React.useRef<HTMLInputElement>(null);
|
const res: UpdateResponse = await req.json();
|
||||||
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const submitPasswordRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [incorrectPassword, setIncorrectPassword] = React.useState(false);
|
if (!res.ok && res.error !== null) {
|
||||||
const [passwordMismatch, setPasswordMismatch] = React.useState(false);
|
switch (res.error) {
|
||||||
const [avatarBig, setAvatarBig] = React.useState(false);
|
case "avatarBig":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [passwordError, setPasswordError] = React.useState<string | null>(null);
|
||||||
|
const initialPasswordValues: PasswordUpdateFormValues = {
|
||||||
|
password: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handlePasswordSubmit(
|
||||||
|
{ password, newPassword }: PasswordUpdateFormValues,
|
||||||
|
{ setFieldError, setSubmitting }: FormikHelpers<PasswordUpdateFormValues>
|
||||||
|
) {
|
||||||
|
console.log(password, newPassword);
|
||||||
|
setSubmitting(true);
|
||||||
|
const req = await fetch("/api/changePassword", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: password,
|
||||||
|
newPassword: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res: UpdateResponse = await req.json();
|
||||||
|
|
||||||
|
if (!res.ok && res.error !== null) {
|
||||||
|
switch (res.error) {
|
||||||
|
case "incorrectPassword":
|
||||||
|
setFieldError("password", "Incorrect password.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<h2 className={styles.header}>User information</h2>
|
<h2 className={styles.userName}>{info.username}</h2>
|
||||||
<form
|
<PrettyForm globalError={globalError}>
|
||||||
onSubmit={async (e) => {
|
<Formik
|
||||||
e.preventDefault();
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
validationSchema={aboutMeSchema}
|
||||||
|
>
|
||||||
|
{({ isSubmitting }) => (
|
||||||
|
<Form className={styles.profileGrid}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
label="Username"
|
||||||
|
defaultValue={info.username}
|
||||||
|
disabled
|
||||||
|
title="You can't change your username."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="displayName"
|
||||||
|
label="Display name"
|
||||||
|
defaultValue={info.displayName}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
defaultValue={info.email}
|
||||||
|
/>
|
||||||
|
|
||||||
// turn the data uri into just base64
|
<Input
|
||||||
const avatarChanged = avatar !== null && avatar !== info.avatar;
|
type="file"
|
||||||
const avatarData = avatarChanged ? avatar?.split(",")[1] : null;
|
name="avatar"
|
||||||
|
label="Avatar"
|
||||||
|
accept="image/png, image/jpeg"
|
||||||
|
customRender={(fieldProps) => (
|
||||||
|
<AvatarChanger
|
||||||
|
currentAvatarBlob={fieldProps.field.value}
|
||||||
|
onChange={(newBlob) =>
|
||||||
|
fieldProps.form.setFieldValue("avatar", newBlob)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
submitRef.current!.disabled = true;
|
<input
|
||||||
const req = await fetch("/api/update", {
|
type="submit"
|
||||||
method: "POST",
|
value="Save"
|
||||||
headers: {
|
className={styles.fancyInput}
|
||||||
"Content-Type": "application/json"
|
disabled={isSubmitting}
|
||||||
},
|
/>
|
||||||
body: JSON.stringify({
|
</Form>
|
||||||
displayName: displayNameRef.current?.value,
|
)}
|
||||||
email: emailRef.current?.value,
|
</Formik>
|
||||||
avatar: avatarData
|
</PrettyForm>
|
||||||
})
|
|
||||||
});
|
|
||||||
submitRef.current!.disabled = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res: UpdateResponse = await req.json();
|
|
||||||
|
|
||||||
if (!res.ok && res.error !== null) {
|
|
||||||
switch (res.error) {
|
|
||||||
case "avatarBig":
|
|
||||||
setAvatarBig(true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.error(req);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
label="Username"
|
|
||||||
defaultValue={info.username}
|
|
||||||
disabled
|
|
||||||
title="You can't change your username."
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="display-name"
|
|
||||||
label="Display name"
|
|
||||||
defaultValue={info.displayName}
|
|
||||||
ref={displayNameRef}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
label="Email"
|
|
||||||
defaultValue={info.email}
|
|
||||||
ref={emailRef}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* why, html gods, why? */}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
name="avatar"
|
|
||||||
accept="image/png, image/jpeg"
|
|
||||||
ref={avatarRef}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="button"
|
|
||||||
value="Choose file"
|
|
||||||
name="avatar"
|
|
||||||
label="Avatar"
|
|
||||||
accept="image/png, image/jpeg"
|
|
||||||
error={avatarBig ? "Avatar is too big." : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
avatarRef.current?.click();
|
|
||||||
|
|
||||||
const eventListener = async () => {
|
|
||||||
avatarRef.current?.removeEventListener("change", eventListener);
|
|
||||||
|
|
||||||
const file = avatarRef.current?.files?.[0];
|
|
||||||
if (file == null) return;
|
|
||||||
|
|
||||||
if (file.size > 1_000_000) {
|
|
||||||
setAvatarBig(true);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
setAvatarBig(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const b64 = await fileAsBase64(file);
|
|
||||||
setAvatar(`data:${file.type};base64,${b64}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
avatarRef.current?.addEventListener("change", eventListener);
|
|
||||||
}}
|
|
||||||
displayImage={avatar ?? fallbackAvatar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.formRow}>
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
value="Save"
|
|
||||||
ref={submitRef}
|
|
||||||
className={styles.fancyInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr className={styles.divider} />
|
<hr className={styles.divider} />
|
||||||
|
|
||||||
<h2 className={styles.header}>Change password</h2>
|
<h2 className={styles.header}>Change password</h2>
|
||||||
<form
|
<PrettyForm globalError={passwordError}>
|
||||||
onSubmit={async (e) => {
|
<Formik
|
||||||
e.preventDefault();
|
initialValues={initialPasswordValues}
|
||||||
setIncorrectPassword(false);
|
onSubmit={handlePasswordSubmit}
|
||||||
setPasswordMismatch(false);
|
validationSchema={passwordUpdateSchema}
|
||||||
|
>
|
||||||
|
{({ isSubmitting }) => (
|
||||||
|
<Form className={styles.profileGrid}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Current"
|
||||||
|
minLength={12}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
if (
|
<Input
|
||||||
newPasswordRef.current?.value !== confirmPasswordRef.current?.value
|
type="password"
|
||||||
) {
|
name="newPassword"
|
||||||
setPasswordMismatch(true);
|
label="New"
|
||||||
return;
|
minLength={12}
|
||||||
}
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
submitPasswordRef.current!.disabled = true;
|
<Input
|
||||||
const req = await fetch("/api/changePassword", {
|
type="password"
|
||||||
method: "POST",
|
name="confirmPassword"
|
||||||
headers: {
|
label="Confirm"
|
||||||
"Content-Type": "application/json"
|
minLength={12}
|
||||||
},
|
required
|
||||||
body: JSON.stringify({
|
/>
|
||||||
currentPassword: currentPasswordRef.current?.value,
|
|
||||||
newPassword: newPasswordRef.current?.value
|
|
||||||
})
|
|
||||||
});
|
|
||||||
submitPasswordRef.current!.disabled = false;
|
|
||||||
|
|
||||||
try {
|
<input
|
||||||
const res: UpdateResponse = await req.json();
|
type="submit"
|
||||||
|
value="Save"
|
||||||
|
className={styles.fancyInput}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</PrettyForm>
|
||||||
|
|
||||||
if (!res.ok && res.error !== null) {
|
<hr className={styles.divider} />
|
||||||
switch (res.error) {
|
<input
|
||||||
case "incorrectPassword":
|
type="button"
|
||||||
setIncorrectPassword(true);
|
value="Log out"
|
||||||
break;
|
className={styles.logout}
|
||||||
}
|
onClick={async () => {
|
||||||
}
|
document.cookie =
|
||||||
} catch {
|
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||||
console.error(req);
|
window.location.href = "/";
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="current-password"
|
|
||||||
label="Current"
|
|
||||||
minLength={12}
|
|
||||||
required
|
|
||||||
ref={currentPasswordRef}
|
|
||||||
error={incorrectPassword ? "Incorrect password." : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="new-password"
|
|
||||||
label="New"
|
|
||||||
minLength={12}
|
|
||||||
required
|
|
||||||
ref={newPasswordRef}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="confirm-password"
|
|
||||||
label="Confirm"
|
|
||||||
ref={confirmPasswordRef}
|
|
||||||
minLength={12}
|
|
||||||
required
|
|
||||||
error={passwordMismatch ? "Passwords do not match." : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.formRow}>
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
value="Change password"
|
|
||||||
ref={submitPasswordRef}
|
|
||||||
className={styles.fancyInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ export default async function Page() {
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
const info = await getUserInfo(user);
|
const info = await getUserInfo(user);
|
||||||
if (info === null) redirect("/login");
|
if (info === null) redirect("/register");
|
||||||
|
|
||||||
return <AboutMe info={info} />;
|
return <AboutMe info={info} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,8 @@ export type DiscordAccessTokenResponse = {
|
||||||
export type DiscordUserResponse = {
|
export type DiscordUserResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordGuildResponse = {
|
export type DiscordGuildResponse = {
|
||||||
|
@ -21,14 +23,14 @@ export function discordRedirectUri() {
|
||||||
return `${process.env.BASE_DOMAIN}oauth/discord/redirect`;
|
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", {
|
const req = await fetch("https://discord.com/api/users/@me", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const res: DiscordUserResponse = await req.json();
|
const res: DiscordUserResponse = await req.json();
|
||||||
return res.id;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDiscordGuilds(token: string) {
|
export async function getDiscordGuilds(token: string) {
|
||||||
|
@ -51,8 +53,5 @@ export async function getDiscordAvatar(token: string) {
|
||||||
const res: DiscordUserResponse = await req.json();
|
const res: DiscordUserResponse = await req.json();
|
||||||
if (res.avatar === null) return null;
|
if (res.avatar === null) return null;
|
||||||
const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`;
|
const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`;
|
||||||
|
return file;
|
||||||
const avatarReq = await fetch(file);
|
|
||||||
const avatarBuffer = await avatarReq.arrayBuffer();
|
|
||||||
return Buffer.from(avatarBuffer);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,9 @@ import { URLSearchParams } from "url";
|
||||||
import {
|
import {
|
||||||
discordRedirectUri,
|
discordRedirectUri,
|
||||||
DiscordAccessTokenResponse,
|
DiscordAccessTokenResponse,
|
||||||
getDiscordID,
|
getDiscordGuilds,
|
||||||
getDiscordGuilds
|
getDiscordUser,
|
||||||
|
getDiscordAvatar
|
||||||
} from "../oauth";
|
} from "../oauth";
|
||||||
import { cookies } from "next/dist/client/components/headers";
|
import { cookies } from "next/dist/client/components/headers";
|
||||||
import prisma from "@/prisma";
|
import prisma from "@/prisma";
|
||||||
|
@ -54,14 +55,14 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json();
|
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 guilds = await getDiscordGuilds(tokenBody.access_token);
|
||||||
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
|
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
|
||||||
|
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true;
|
for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true;
|
||||||
if (!allowed) {
|
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 });
|
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({
|
const discordAuth = await prisma.discordAuth.upsert({
|
||||||
where: {
|
where: {
|
||||||
id
|
id: discordUser.id
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id,
|
id: discordUser.id,
|
||||||
accessToken: tokenBody.access_token,
|
accessToken: tokenBody.access_token,
|
||||||
refreshToken: tokenBody.refresh_token,
|
refreshToken: tokenBody.refresh_token,
|
||||||
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000),
|
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, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
|
"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="/login">login</a>
|
||||||
<a href="/oauth/discord/login">register (discord)</a>
|
<a href="/oauth/discord/login">register (discord)</a>
|
||||||
|
<a href="/oauth/github/login">register (github)</a>
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form input[type="submit"] {
|
.form *[type="submit"] {
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
font-size: 140%;
|
font-size: 140%;
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
|
@ -18,11 +18,6 @@
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonContainer input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
color: var(--fg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.formRow {
|
.formRow {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,207 +1,167 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { InputHTMLAttributes } from "react";
|
import React from "react";
|
||||||
import { HTMLInputTypeAttribute } from "react";
|
|
||||||
import styles from "./RegisterForm.module.css";
|
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";
|
||||||
|
import AvatarChanger from "@/components/AvatarChanger";
|
||||||
|
|
||||||
type RegisterResponse = {
|
type RegisterResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputProps = {
|
export default function RegisterForm({
|
||||||
label: string;
|
initialDisplayName,
|
||||||
name: string;
|
initialEmail,
|
||||||
hint?: string;
|
initialAvatarBase64
|
||||||
type: HTMLInputTypeAttribute;
|
}: {
|
||||||
placeholder?: string;
|
initialDisplayName?: string;
|
||||||
error?: string;
|
initialEmail?: string;
|
||||||
} & InputHTMLAttributes<HTMLInputElement>;
|
initialAvatarBase64?: string;
|
||||||
|
}) {
|
||||||
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
const initialValues: RegisterFormValues = {
|
||||||
return (
|
username: "",
|
||||||
<div className={styles.formRow}>
|
displayName: initialDisplayName ?? "",
|
||||||
<label htmlFor={props.id}>{props.label}</label>
|
email: initialEmail ?? "",
|
||||||
<input {...props} ref={ref} />
|
password: "",
|
||||||
{props.error != null ? (
|
confirmPassword: "",
|
||||||
<p className={styles.error}>{props.error}</p>
|
avatar: initialAvatarBase64
|
||||||
) : (
|
};
|
||||||
<p className={styles.hint}>{props.hint}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Input.displayName = "Input";
|
|
||||||
|
|
||||||
async function fileAsBase64(f: File) {
|
async function handleFormSubmit(
|
||||||
const reader = new FileReader();
|
{ avatar, username, displayName, email, password }: RegisterFormValues,
|
||||||
reader.readAsArrayBuffer(f);
|
{ setFieldError, setSubmitting }: FormikHelpers<RegisterFormValues>
|
||||||
return new Promise<string>((resolve, reject) => {
|
) {
|
||||||
reader.onload = () => {
|
setSubmitting(true);
|
||||||
const result = reader.result as ArrayBuffer;
|
|
||||||
const buffer = Buffer.from(result);
|
|
||||||
resolve(buffer.toString("base64"));
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RegisterForm() {
|
const resp = await fetch(`/api/register`, {
|
||||||
const usernameRef = React.useRef<HTMLInputElement>(null);
|
method: "POST",
|
||||||
const displayNameRef = React.useRef<HTMLInputElement>(null);
|
headers: {
|
||||||
const emailRef = React.useRef<HTMLInputElement>(null);
|
"Content-Type": "application/json"
|
||||||
const passwordRef = React.useRef<HTMLInputElement>(null);
|
},
|
||||||
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
|
body: JSON.stringify({
|
||||||
const avatarRef = React.useRef<HTMLInputElement>(null);
|
username,
|
||||||
const submitRef = React.useRef<HTMLInputElement>(null);
|
displayName,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
avatarBase64: avatar != null ? avatar.split(",")[1] : undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
const [usernameTaken, setUsernameTaken] = React.useState(false);
|
try {
|
||||||
const [passwordMismatch, setPasswordMismatch] = React.useState(false);
|
const res: RegisterResponse = await resp.json();
|
||||||
const [avatarBig, setAvatarBig] = React.useState(false);
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.form}>
|
<PrettyForm globalError={globalError}>
|
||||||
<form
|
<Formik
|
||||||
onSubmit={async (e) => {
|
initialValues={initialValues}
|
||||||
e.preventDefault();
|
onSubmit={handleFormSubmit}
|
||||||
|
validationSchema={registerSchema}
|
||||||
const [username, displayName, email, password, confirmPassword] = [
|
enableReinitialize
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (avatar != null && avatar?.size > 1_000_000) {
|
|
||||||
setAvatarBig(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitRef.current!.disabled = true;
|
|
||||||
const req = await fetch(`/api/register`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
displayName,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
avatarBase64
|
|
||||||
})
|
|
||||||
});
|
|
||||||
submitRef.current!.disabled = false;
|
|
||||||
|
|
||||||
if (req.status === 500) {
|
|
||||||
// something real bad fucked up
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res: RegisterResponse = await req.json();
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.href = "/me";
|
|
||||||
} else {
|
|
||||||
if (res.error !== null) {
|
|
||||||
switch (res.error) {
|
|
||||||
case "avatarBig":
|
|
||||||
setAvatarBig(true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "usernameTaken":
|
|
||||||
setUsernameTaken(true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.error(req);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Input
|
{({ isSubmitting }) => (
|
||||||
hint="The username you'll use to log into NotNet services. By standard, this should be lowercase, and usually your first name."
|
<Form>
|
||||||
type="text"
|
<Input
|
||||||
name="username"
|
hint="The username you'll use to log into NotNet services. By standard, this should be lowercase, and usually your first name."
|
||||||
label="Username"
|
type="text"
|
||||||
placeholder="julian"
|
name="username"
|
||||||
ref={usernameRef}
|
label="Username"
|
||||||
required
|
placeholder="julian"
|
||||||
error={usernameTaken ? "Username is taken." : undefined}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
hint="Your display name - this can be what you go by online, for example."
|
hint="Your display name - this can be what you go by online, for example."
|
||||||
type="text"
|
type="text"
|
||||||
name="display-name"
|
name="displayName"
|
||||||
label="Display name"
|
label="Display name"
|
||||||
placeholder="NotNite"
|
placeholder="NotNite"
|
||||||
ref={displayNameRef}
|
/>
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
hint="Your email address. An inbox will be created on @n2.pm that forwards to this email."
|
hint="Your email address. An inbox will be created on @n2.pm that forwards to this email."
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="hi@notnite.com"
|
placeholder="hi@notnite.com"
|
||||||
ref={emailRef}
|
/>
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
hint="Your password. To secure NotNet services, make this a strong and long password."
|
hint="Your password. To secure NotNet services, make this a strong and long password."
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
placeholder="deeznuts47"
|
placeholder="deeznuts47"
|
||||||
minLength={12}
|
minLength={12}
|
||||||
ref={passwordRef}
|
autoComplete="new-password"
|
||||||
required
|
/>
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
name="confirm-password"
|
name="confirmPassword"
|
||||||
label="Confirm password"
|
label="Confirm password"
|
||||||
placeholder="deeznuts47"
|
placeholder="deeznuts47"
|
||||||
minLength={12}
|
minLength={12}
|
||||||
ref={confirmPasswordRef}
|
/>
|
||||||
required
|
|
||||||
error={passwordMismatch ? "Passwords do not match." : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
hint={
|
hint={
|
||||||
"This image will automatically be used as your avatar with supported services - maximum 1 MB. " +
|
"This image will automatically be used as your avatar with supported services - maximum 1 MB. "
|
||||||
"Will use the avatar of the service you signed up with if not provided."
|
}
|
||||||
}
|
type="file"
|
||||||
type="file"
|
name="avatar"
|
||||||
name="avatar"
|
label="Avatar"
|
||||||
label="Avatar"
|
accept="image/png, image/jpeg"
|
||||||
accept="image/png, image/jpeg"
|
customRender={(fieldProps) => (
|
||||||
ref={avatarRef}
|
<AvatarChanger
|
||||||
error={avatarBig ? "Avatar is too big." : undefined}
|
currentAvatarBlob={fieldProps.field.value}
|
||||||
/>
|
onChange={(newBlob) =>
|
||||||
|
fieldProps.form.setFieldValue("avatar", newBlob)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<input type="submit" value="Join NotNet!" ref={submitRef} />
|
<HugeSubmit value="Join NotNet!" disabled={isSubmitting} />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</Form>
|
||||||
</div>
|
)}
|
||||||
|
</Formik>
|
||||||
|
</PrettyForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,63 @@
|
||||||
import { cookies } from "next/dist/client/components/headers";
|
import { cookies } from "next/dist/client/components/headers";
|
||||||
import styles from "@/app/page.module.css";
|
import styles from "@/app/page.module.css";
|
||||||
import RegisterForm from "./RegisterForm";
|
import RegisterForm from "./RegisterForm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
|
import { ensureJpg } from "@/image";
|
||||||
|
|
||||||
export default function Page() {
|
function avatarUrlAllowed(url: URL): boolean {
|
||||||
|
let github = url.hostname === "avatars.githubusercontent.com";
|
||||||
|
let discord = url.hostname === "cdn.discordapp.com";
|
||||||
|
|
||||||
|
if (discord && !url.pathname.startsWith("/avatars")) return false;
|
||||||
|
return github || discord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams: {
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
const cookieStore = cookies();
|
const cookieStore = cookies();
|
||||||
const ticket = cookieStore.get("ticket");
|
const ticket = cookieStore.get("ticket");
|
||||||
if (ticket === null) redirect("/");
|
|
||||||
|
if (ticket === null) {
|
||||||
|
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);
|
||||||
|
if (buffer.length <= 1_000_000) {
|
||||||
|
// I hope you are doing well, you deserve the best of luck while working on this project -Ari
|
||||||
|
try {
|
||||||
|
const jpg = await ensureJpg(buffer);
|
||||||
|
initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<RegisterForm />
|
<RegisterForm
|
||||||
|
initialDisplayName={searchParams.displayName}
|
||||||
|
initialEmail={searchParams.email}
|
||||||
|
initialAvatarBase64={initialAvatarBase64}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,14 +9,14 @@ const logger = getLogger("auth.ts");
|
||||||
export async function getUser() {
|
export async function getUser() {
|
||||||
const cookieStore = cookies();
|
const cookieStore = cookies();
|
||||||
const cookieTicket = cookieStore.get("ticket");
|
const cookieTicket = cookieStore.get("ticket");
|
||||||
if (cookieTicket === null) return null;
|
if (cookieTicket == null) return null;
|
||||||
|
|
||||||
const ticket = await prisma.authTicket.findFirst({
|
const ticket = await prisma.authTicket.findFirst({
|
||||||
where: {
|
where: {
|
||||||
ticket: cookieTicket?.value
|
ticket: cookieTicket?.value
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (ticket === null) return null;
|
if (ticket == null) return null;
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
.avatarChanger {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarChanger :is(img, svg) {
|
||||||
|
width: 3em;
|
||||||
|
height: 3em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarChanger button svg {
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarChanger input[type=file] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
padding: 0.25em 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React, { ChangeEvent } from "react";
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
|
import styles from "./AvatarChanger.module.css";
|
||||||
|
import { fileAsBase64 } from "@/forms";
|
||||||
|
import UploadIcon from "./icons/UploadIcon";
|
||||||
|
import UserIcon from "./icons/UserIcon";
|
||||||
|
|
||||||
|
export default function AvatarChanger({
|
||||||
|
currentAvatarBlob,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
currentAvatarBlob: string | null;
|
||||||
|
onChange: (newAvatar: string) => void;
|
||||||
|
}) {
|
||||||
|
const input = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (file == null) return;
|
||||||
|
|
||||||
|
const base64 = await fileAsBase64(file);
|
||||||
|
onChange(`data:${file.type};base64,${base64}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// I give you the most support and well wishes while you work on this project -Ari
|
||||||
|
return (
|
||||||
|
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
|
||||||
|
{currentAvatarBlob != null ? (
|
||||||
|
<img src={currentAvatarBlob!} alt="Your avatar" />
|
||||||
|
) : (
|
||||||
|
<UserIcon />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.uploadButton}
|
||||||
|
onClick={() => {
|
||||||
|
input.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadIcon />
|
||||||
|
Upload a new avatar
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png, image/jpeg"
|
||||||
|
ref={input}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -9,14 +9,14 @@ type ColorScheme = {
|
||||||
|
|
||||||
bg: string;
|
bg: string;
|
||||||
bgDark: string;
|
bgDark: string;
|
||||||
bgDarker?: string;
|
bgDarker: string;
|
||||||
|
|
||||||
fg: string;
|
fg: string;
|
||||||
fgDark: string;
|
fgDark: string;
|
||||||
fgDarker?: string;
|
fgDarker: string;
|
||||||
|
|
||||||
error?: string;
|
error: string;
|
||||||
warning?: string;
|
warning: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const colors: ColorScheme[] = [
|
const colors: ColorScheme[] = [
|
||||||
|
@ -153,8 +153,10 @@ function set(colorScheme: ColorScheme) {
|
||||||
const fixedColors = {
|
const fixedColors = {
|
||||||
"--bg": colorScheme.bg,
|
"--bg": colorScheme.bg,
|
||||||
"--bg-dark": colorScheme.bgDark,
|
"--bg-dark": colorScheme.bgDark,
|
||||||
|
"--bg-darker": colorScheme.bgDarker,
|
||||||
"--fg": colorScheme.fg,
|
"--fg": colorScheme.fg,
|
||||||
"--fg-dark": colorScheme.fgDark,
|
"--fg-dark": colorScheme.fgDark,
|
||||||
|
"--fg-darker": colorScheme.fgDarker,
|
||||||
"--error": colorScheme.error ?? fallback.error!,
|
"--error": colorScheme.error ?? fallback.error!,
|
||||||
"--warning": colorScheme.warning ?? fallback.warning!
|
"--warning": colorScheme.warning ?? fallback.warning!
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,73 @@
|
||||||
|
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
|
||||||
|
import React from "react";
|
||||||
|
import styles from "./Input.module.css";
|
||||||
|
|
||||||
|
type CustomInputProps<T> = {
|
||||||
|
customRender?: (fieldProps: FieldProps) => React.ReactNode;
|
||||||
|
|
||||||
|
customOnChange?: (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
form: FormikProps<T>
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Input<T>(
|
||||||
|
props: CustomInputProps<T> &
|
||||||
|
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }>
|
||||||
|
) {
|
||||||
|
const generatedId = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label htmlFor={generatedId}>{props.label}</label>
|
||||||
|
<Field id={generatedId} {...props}>
|
||||||
|
{(fieldProps: FieldProps) => {
|
||||||
|
let { 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 (
|
||||||
|
<>
|
||||||
|
{props.customRender == null ? (
|
||||||
|
<input
|
||||||
|
type={props.type}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
disabled={props.disabled}
|
||||||
|
title={props.title}
|
||||||
|
{...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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
props.customRender(fieldProps)
|
||||||
|
)}
|
||||||
|
{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,39 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function UploadIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
stroke="none"
|
||||||
|
strokeWidth="1"
|
||||||
|
fill="none"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16,64 L16,92 C16,103.045695 24.954305,112 36,112 L92,112 C103.045695,112 112,103.045695 112,92 L112,64 L112,64"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="13"
|
||||||
|
></path>
|
||||||
|
<line
|
||||||
|
x1="64"
|
||||||
|
y1="80"
|
||||||
|
x2="64"
|
||||||
|
y2="16"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="13"
|
||||||
|
></line>
|
||||||
|
<polyline
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="13"
|
||||||
|
points="32 48 64 16 96 48"
|
||||||
|
></polyline>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||||
|
<circle fill="currentColor" cx="64" cy="48" r="32"></circle>
|
||||||
|
<path
|
||||||
|
d="M112,128 C112,101.490332 90.509668,80 64,80 C37.490332,80 16,101.490332 16,128 C16,128 112,128 112,128 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
71
src/ldap.ts
71
src/ldap.ts
|
@ -1,11 +1,11 @@
|
||||||
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
||||||
import { Client } from "ldapts";
|
import { Client } from "ldapts";
|
||||||
import { gql } from "./__generated__";
|
import { gql } from "./__generated__";
|
||||||
import sharp from "sharp";
|
|
||||||
import { BerWriter } from "asn1";
|
import { BerWriter } from "asn1";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { ensureJpg } from "@/image";
|
||||||
|
|
||||||
type LLDAPAuthResponse = {
|
export type LLDAPAuthResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
};
|
};
|
||||||
|
@ -21,31 +21,29 @@ export type UserInfo = {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let ldapClient: Client | null = null;
|
|
||||||
async function getLdapClient() {
|
async function getLdapClient() {
|
||||||
if (ldapClient === null) {
|
if (global.ldapClient == null) {
|
||||||
ldapClient = new Client({
|
global.ldapClient = new Client({
|
||||||
url: `ldap://${process.env.LDAP_HOST}:3890`
|
url: `ldap://${process.env.LDAP_HOST}:3890`
|
||||||
});
|
});
|
||||||
|
|
||||||
const full = `uid=${process.env.LDAP_BIND_USER},ou=people,${process.env.LDAP_DC}`;
|
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() {
|
async function regenAuthToken() {
|
||||||
if (authResponse !== null) {
|
if (global.authResponse != null) {
|
||||||
const url = `http://${process.env.LDAP_HOST}:17170/auth/refresh`;
|
const url = `http://${process.env.LDAP_HOST}:17170/auth/refresh`;
|
||||||
const req = await fetch(url, {
|
const req = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
"Refresh-Token": authResponse.refreshToken
|
"Refresh-Token": global.authResponse.refreshToken
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const res: LLDAPRefreshResponse = await req.json();
|
const res: LLDAPRefreshResponse = await req.json();
|
||||||
authResponse.token = res.token;
|
global.authResponse.token = res.token;
|
||||||
} else {
|
} else {
|
||||||
const url = `http://${process.env.LDAP_HOST}:17170/auth/simple/login`;
|
const url = `http://${process.env.LDAP_HOST}:17170/auth/simple/login`;
|
||||||
const req = await fetch(url, {
|
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
|
// valid for one day, so refresh every 12 hours
|
||||||
|
@ -67,31 +65,35 @@ async function regenAuthToken() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAuthToken() {
|
async function getAuthToken() {
|
||||||
if (authResponse === null) await regenAuthToken();
|
if (global.authResponse == null) await regenAuthToken();
|
||||||
return authResponse!.token;
|
return global.authResponse!.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
let graphQLClient: ApolloClient<any> | null = null;
|
|
||||||
let graphQLCache = new InMemoryCache();
|
|
||||||
let graphQLAuthToken: string | null = null;
|
|
||||||
async function getGraphQLClient() {
|
async function getGraphQLClient() {
|
||||||
if (authResponse === null) {
|
if (global.authResponse == null) {
|
||||||
await getAuthToken();
|
await getAuthToken();
|
||||||
graphQLAuthToken = authResponse!.token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We keep track of the auth token we used in the client, so we can
|
// Remake the client every time because Apollo caching is fucking stupid
|
||||||
// recreate it when it expires/refreshes
|
let graphQLClient = new ApolloClient({
|
||||||
if (graphQLClient === null || graphQLAuthToken !== authResponse!.token) {
|
uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`,
|
||||||
graphQLClient = new ApolloClient({
|
cache: new InMemoryCache(),
|
||||||
uri: `http://${process.env.LDAP_HOST}:17170/api/graphql`,
|
defaultOptions: {
|
||||||
cache: graphQLCache,
|
watchQuery: {
|
||||||
headers: {
|
fetchPolicy: "no-cache",
|
||||||
Authorization: `Bearer ${authResponse!.token}`
|
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;
|
return graphQLClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,12 +112,6 @@ export async function getUsers() {
|
||||||
return query.data.users;
|
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(
|
export async function createUser(
|
||||||
username: string,
|
username: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
@ -176,9 +172,8 @@ export async function validateUser(username: string, password: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserExists(username: string) {
|
export async function checkUserExists(username: string) {
|
||||||
return (await getUsers()).find(
|
const users = await getUsers();
|
||||||
(u) => u.id.toLowerCase() === username.toLowerCase()
|
return users.find((u) => u.id.toLowerCase() === username.toLowerCase());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserInfo(user: User) {
|
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";
|
import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth";
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// refresh 6 hours before expiry
|
async function refreshDiscordTokens(prisma: PrismaClient) {
|
||||||
async function refreshDiscordTokens() {
|
// refresh 6 hours before expiry
|
||||||
const refreshWindow = 6 * 60 * 60 * 1000;
|
const refreshWindow = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const discordAuths = await prisma.discordAuth.findMany({
|
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({
|
const expired = await prisma.authTicket.findMany({
|
||||||
where: {
|
where: {
|
||||||
expiresAt: {
|
expiresAt: {
|
||||||
|
@ -61,9 +60,17 @@ async function expireTickets() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(async () => {
|
let prisma: PrismaClient;
|
||||||
await refreshDiscordTokens();
|
if (global.prisma == undefined) {
|
||||||
await expireTickets();
|
global.prisma = new PrismaClient();
|
||||||
}, 60 * 1000);
|
prisma = global.prisma;
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
await refreshDiscordTokens(prisma);
|
||||||
|
await expireTickets(prisma);
|
||||||
|
}, 60 * 1000);
|
||||||
|
} else {
|
||||||
|
prisma = global.prisma;
|
||||||
|
}
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
const REQUIRED = "Required.";
|
||||||
|
const USERNAME = Yup.string()
|
||||||
|
.required(REQUIRED)
|
||||||
|
.min(1, "Username is too short.");
|
||||||
|
const DISPLAY_NAME = Yup.string()
|
||||||
|
.required(REQUIRED)
|
||||||
|
.min(1, "Display name is too short.");
|
||||||
|
const EMAIL = Yup.string().required(REQUIRED).email("Not an email.");
|
||||||
|
|
||||||
|
const PASSWORD = Yup.string()
|
||||||
|
.required(REQUIRED)
|
||||||
|
.min(12, "Password must be at least 12 characters long.");
|
||||||
|
const CONFIRM_PASSWORD = (name: string) =>
|
||||||
|
Yup.string()
|
||||||
|
.required(REQUIRED)
|
||||||
|
.oneOf([Yup.ref(name, {})], "Passwords must match.");
|
||||||
|
|
||||||
|
const AVATAR = Yup.string().test(
|
||||||
|
"file-size",
|
||||||
|
"File is bigger than 1 MB.",
|
||||||
|
(value) => {
|
||||||
|
if (value == null) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(value, "base64");
|
||||||
|
return buf.length <= 1_000_000;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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: DISPLAY_NAME,
|
||||||
|
email: EMAIL,
|
||||||
|
password: PASSWORD,
|
||||||
|
confirmPassword: CONFIRM_PASSWORD("password"),
|
||||||
|
avatar: AVATAR
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RegisterFormValues {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aboutMeSchema: Yup.Schema<AboutMeFormValues> = Yup.object().shape({
|
||||||
|
username: USERNAME,
|
||||||
|
displayName: DISPLAY_NAME,
|
||||||
|
email: EMAIL,
|
||||||
|
avatar: AVATAR
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AboutMeFormValues {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const passwordUpdateSchema: Yup.Schema<PasswordUpdateFormValues> =
|
||||||
|
Yup.object().shape({
|
||||||
|
password: PASSWORD,
|
||||||
|
newPassword: PASSWORD,
|
||||||
|
confirmPassword: CONFIRM_PASSWORD("newPassword")
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface PasswordUpdateFormValues {
|
||||||
|
password: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
Loading…
Reference in New Issue