feat(account): add basic account management page + Discord OAuth

This commit is contained in:
Monty 2021-10-15 21:40:21 +02:00
parent b4ddffac50
commit aef4036212
No known key found for this signature in database
GPG Key ID: 78B405B6520E1012
7 changed files with 599 additions and 1 deletions

View File

@ -1,10 +1,15 @@
{
"http": {
"port": 80
"port": 80,
"base_url": "http://localhost:80"
},
"trello": {
"api_key": "key",
"api_token": "token",
"board_name": "name"
},
"discord": {
"client_id": "client_id",
"client_secret": "client_secret"
}
}

27
package-lock.json generated
View File

@ -378,6 +378,11 @@
"which": "^2.0.1"
}
},
"data-uri-to-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz",
"integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -718,6 +723,14 @@
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
},
"fetch-blob": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.2.tgz",
"integrity": "sha512-hunJbvy/6OLjCD0uuhLdp0mMPzP/yd2ssd1t2FCJsaA7wkWhpbp9xfuNVpv7Ll4jFhzp6T4LAupSiV9uOeg0VQ==",
"requires": {
"web-streams-polyfill": "^3.0.3"
}
},
"file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -1168,6 +1181,15 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz",
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw=="
},
"node-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.0.0.tgz",
"integrity": "sha512-bKMI+C7/T/SPU1lKnbQbwxptpCrG9ashG+VkytmXCPZyuM9jB6VU+hY0oi4lC8LxTtAeWdckNCTa3nrGsAdA3Q==",
"requires": {
"data-uri-to-buffer": "^3.0.1",
"fetch-blob": "^3.1.2"
}
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -1668,6 +1690,11 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
},
"web-streams-polyfill": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.1.1.tgz",
"integrity": "sha512-Czi3fG883e96T4DLEPRvufrF2ydhOOW1+1a6c3gNjH2aIh50DNFBdfwh2AKoOf1rXvpvavAoA11Qdq9+BKjE0Q=="
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -27,6 +27,7 @@
"ioredis": "^4.26.0",
"marked": "^3.0.4",
"morgan": "^1.10.0",
"node-fetch": "^3.0.0",
"redis-json": "^5.0.0",
"trello": "^0.10.0"
},

View File

@ -0,0 +1,226 @@
.account-wrapper {
display: grid;
column-gap: 60px;
margin-top: 80px;
color: var(--text-secondary);
}
/* Account settings navbar */
.account-sidebar .user {
text-align: center;
margin: 55px auto;
width: fit-content;
}
.account-sidebar .user .miiname {
font-size: 1.3rem;
color: var(--text);
margin: 8px 0 4px;
}
.account-sidebar .user .username {
margin: 0px;
}
.account-sidebar .user .mii {
width: 300px;
height: 300px;
border-radius: 100%;
background: var(--btn-secondary);
}
/* Settings */
.settings-wrapper {
display: grid;
grid-column-start: 2;
grid-template-columns: 50%;
column-gap: 20px;
}
.settings-wrapper a {
color: #9d6ff3;
text-decoration: none;
font-weight: bold;
}
.settings-wrapper a:hover {
text-decoration: underline;
}
.settings-wrapper h2.section-header {
margin-top: 40px;
grid-column: 1 / 3;
color: var(--text);
}
.setting-card {
display: grid;
grid-template-rows: 35px repeat(2, auto);
row-gap: 24px;
position: relative;
border-radius: 10px;
background: #2a2f50;
padding: 48px 60px;
}
.setting-card * {
margin: 0;
}
.setting-card .edit {
color: var(--text-secondary);
background: #383f6b;
border-radius: 100%;
position: absolute;
top: 42px;
right: 48px;
width: 24px;
height: 24px;
padding: 12px;
}
.setting-card .edit:hover {
background: #383f6b;
color: var(--text);
}
.setting-card .header {
color: var(--text);
}
.setting-card .setting-list {
display: grid;
grid-template-columns: repeat(2, auto);
gap: 24px;
list-style: none;
padding: 0;
}
.setting-card .setting-list p.label {
color: var(--text);
margin-bottom: 4px;
}
fieldset {
height: min-content;
padding: 0;
border: none;
}
.setting-card .server-selection {
display: flex;
border-radius: 5px;
overflow: hidden;
background: #383f6b;
}
.setting-card .server-selection input {
display: none;
}
.server-selection input + label {
display: flex;
flex-flow: column;
align-items: center;
flex: 50%;
color: var(--text-secondary);
padding: 40px;
justify-content: space-between;
cursor: pointer;
}
.server-selection input + label h2 {
margin-top: 12px;
color: var(--text-secondary);
}
.server-selection input:checked + label,
.server-selection input:checked + label h2 {
background: var(--theme);
color: var(--text);
}
.setting-card.sign-in-history a button {
width: 100%;
padding: 12px 48px;
cursor: pointer;
background: #383f6b;
}
.setting-card input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
background: #383f6b;
padding: 12px;
margin: 4px;
margin-left: 0;
border-radius: 4px;
vertical-align: -65%;
}
.setting-card input[type="checkbox"]:checked {
background: no-repeat center/contain url(../images/check.svg), var(--theme);
}
.setting-card.span-both-columns {
grid-column: 1 / span 2;
}
.account-link-notice {
display: flex;
justify-content: center;
position: absolute;
top: -150px;
width: 100%;
animation: account-link-notice 5s;
}
@keyframes account-link-notice {
0% {top: -150px}
20% {top: 35px}
80% {top: 35px}
100% {top: -150px}
}
.account-link-notice div {
background: #37A985;
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
footer {
margin-top: 80px;
}
@media screen and (max-width: 1500px) {
.account-wrapper {
margin: 20px 0;
}
.settings-wrapper {
grid-column-start: 1;
}
.account-sidebar {
margin: 0;
}
.account-sidebar .user .mii {
width: 250px;
height: 250px;
}
}
@media screen and (max-width: 1050px) {
.settings-wrapper {
display: block;
width: 100%;
}
.setting-card {
margin-bottom: 24px;
}
}
@media screen and (max-width: 550px) {
.setting-card {
padding: 24px;
}
.setting-card .edit {
top: 24px;
right: 24px;
}
.setting-card .setting-list {
grid-template-columns: auto;
}
.setting-card .server-selection {
flex-flow: column;
}
}

185
src/routers/account.js Normal file
View File

@ -0,0 +1,185 @@
const { Router } = require('express');
const util = require('../util');
const router = new Router();
const fetch = (...args) =>
import('node-fetch').then(({ default: fetch }) => fetch(...args));
const config = require('../../config.json');
// A test PNID
const account = {
access_level: 0, // 0: standard, 1: tester, 2: mod?, 3. dev?, 4. god
server_access_level: 'prod', // prod, test, dev
username: 'PN_Monty',
password: 'thelegend27',
birthdate: '13/04/2004', // what format
gender: 'what even is this', // what format
country: 'Italy', // what format
language: 'JS smh', // what format
email: {
address: 'notascam@freecreditreport.com',
primary: true, // do we need to let the user change anything?
parent: true,
reachable: true,
validated: true,
},
region: 'don\'t know the numbers',
timezone: {
name: 'Europe/Rome',
offset: 3600, // haven't checked if this is correct, just an assumption
},
mii: {
name: 'Monty',
image_url:
'https://studio.mii.nintendo.com/miis/image.png?data=00080f50595a606a6268696f757883969b9aa1a8b1b8b7bebdc5cccbd1d8620a121a181119111916222d3444484c4b&type=face&expression=normal&width=512',
},
flags: {
marketing: true,
off_device: true, // Forgot what this does
},
devices: [
{
is_emulator: {
type: false,
},
console_type: {
type: 'wup',
},
device_attributes: {
created_date: '23/07/2021', // what format
name: 'Wii U', // ?
value: 'what', // what format
},
},
{
is_emulator: {
type: false,
},
console_type: {
type: 'wup',
},
device_attributes: {
created_date: '24/05/2020',
name: 'Windows', // ?
value: 'what',
},
},
],
connections: { // This needs to be added to the schema
discord: {
id: '406125028065804289',
access_token: 'GiJ2Osi6LpYS7uLgZNyvCbRtpRopv1', // This only has the identify scope so eh, who cares if it gets leaked
expires_on: 604800,
refresh_token: 'VeQMa6zp2Rx77PjhiNFJbpKrpz2gX2',
scope: 'identify',
token_type: 'Bearer',
},
},
};
router.get('/', async (request, response) => {
const justLinked = request.cookies.justLinked;
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
let discordUser;
if (account.connections.discord.access_token) {
// TODO: check if the token is valid, if not refresh it.
const discord = account.connections.discord;
const fetchDiscordUser = await fetch('https://discord.com/api/users/@me', {
method: 'get',
headers: {
'Authorization': `${discord.token_type} ${discord.access_token}`
},
});
discordUser = await fetchDiscordUser.json();
}
// test user
discordUser = {
id: '406125028065804289',
username: 'montylion',
avatar: '5184b3dc388bdad1a93eb58694a58398',
discriminator: '3581',
public_flags: 64,
flags: 64,
banner: null,
banner_color: '#ff7081',
accent_color: 16740481,
locale: 'en-US',
mfa_enabled: true,
};
const isTester = () => {
return account.access_level > 0;
};
response.clearCookie('justLinked');
// TODO: make the sign in history only show the first 2/4 devices
response.render('account', {
layout: 'main',
locale,
localeString: reqLocale.toString(),
account,
isTester,
discordUser,
justLinked
});
});
router.get('/connect/discord', async (request, response) => {
//const reqLocale = request.locale;
//const locale = util.getLocale(reqLocale.region, reqLocale.language);
const codeExchange = await fetch('https://discord.com/api/oauth2/token', {
method: 'post',
body: new URLSearchParams({
client_id: config.discord.client_id,
client_secret: config.discord.client_secret,
grant_type: 'authorization_code',
code: request.query.code,
redirect_uri: `${config.http.base_url}/account/connect/discord`,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const token = await codeExchange.json();
/*
// Send the oauth tokens to the account server
fetch('account server endpoint', {
method: 'post',
body: {
access_token: token.access_token,
token_type: token.token_type,
expires_on: new Date().getTime() + token.expires_in,
refresh_token: token.refresh_token,
scope: token.scope,
},
headers: {
'Content-Type': 'application/json',
}
});
*/
/* Token refresh function (should be moved to util.js)
const refreshTokens = await fetch('https://discord.com/api/oauth2/token', {
method: 'post',
body: new URLSearchParams({
client_id: config.discord.client_id,
client_secret: config.discord.client_secret,
grant_type: 'refresh_token',
refresh_token: account.connections.discord.refresh_token
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const newTokens = await refreshTokens.json();
console.log(newTokens);
*/
// This sets a cookie to tell the account page to show the "Account linked successfully" notice, and redirects.
response.cookie('justLinked', 'discord').redirect('/account');
});
module.exports = router;

View File

@ -23,6 +23,7 @@ const routers = {
home: require('./routers/home'),
faq: require('./routers/faq'),
progress: require('./routers/progress'),
account: require('./routers/account'),
blog: require('./routers/blog'),
localization: require('./routers/localization')
};
@ -70,6 +71,7 @@ app.use(expressLocale({
app.use('/', routers.home);
app.use('/faq', routers.faq);
app.use('/progress', routers.progress);
app.use('/account', routers.account);
app.use('/localization', routers.localization);
app.use('/blog', routers.blog);

152
views/account.handlebars Normal file
View File

@ -0,0 +1,152 @@
<link rel="stylesheet" href="/assets/css/account.css" />
<div class="wrapper">
{{> header}}
<div class="account-wrapper">
<div class="account-sidebar">
<div class="user">
<img src="{{account.mii.image_url}}&bgColor=3E446600" class="mii" />
<p class="miiname">{{account.mii.name}}</p>
<p class="username">PNID: {{account.username}}</p>
</div>
</div>
<div class="settings-wrapper">
<h2 class="section-header" id="user-settings">User settings</h2>
<div class="setting-card">
<h2 class="header">Profile</h2>
<a href="/account/edit/profile" class="edit">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
</a>
<ul class="setting-list">
<li>
<p class="label">Nickname</p>
<p class="value">{{account.mii.name}}</p>
</li>
<li>
<p class="label">Birth date</p>
<p class="value">{{account.birthdate}}</p>
</li>
<li>
<p class="label">Gender</p>
<p class="value">{{account.gender}}</p>
</li>
<li>
<p class="label">Country/region</p>
<p class="value">{{account.country}}</p>
</li>
<li>
<p class="label">Timezone</p>
<p class="value">{{account.timezone.name}}</p>
</li>
</ul>
</div>
<div class="setting-card">
<h2 class="header">Servers</h2>
<fieldset {{#if isTester}}{{else}}disabled{{/if}}>
<form class="server-selection" id="server">
<input type="radio" id="prod" name="server_selection" value="prod">
<label for="prod">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline>
<polyline points="7.5 19.79 7.5 14.6 3 12"></polyline>
<polyline points="21 12 16.5 14.6 16.5 19.79"></polyline>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<h2>Production</h2>
</label>
<input type="radio" id="beta" name="server_selection" value="beta">
<label for="beta">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="2,21 22,21 14,11.5 14,5 10,3 10,11.5"></polygon>
</svg>
<h2>Beta</h2>
</label>
</form>
</fieldset>
{{#if discordUser}}
{{#if isTester }}
<p>Connected as {{ discordUser.username }}#{{ discordUser.discriminator }}.</p>
{{else}}
<p>{{ discordUser.username }}#{{ discordUser.discriminator }} doesn't appear to be a @Tester account. Check that you have the role on Discord. [should probably fix the wording]</p>
{{/if}}
{{else}}
<p>Beta servers are exclusive to testers. If you're a tester, connect your Discord account <a href="https://discord.com/api/oauth2/authorize?client_id=896508671742345266&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Faccount%2Fconnect%2Fdiscord&response_type=code&scope=identify">here</a>.</p>
{{/if}}
</div>
<h2 class="section-header" id="security">Sign in and security</h2>
<div class="setting-card">
<h2 class="header">Account</h2>
<a href="/account/edit/login-info" class="edit">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
</a>
<ul class="setting-list">
<li>
<p class="label">Email</p>
<p class="value">{{account.email.address}}</p>
</li>
<li>
<p class="label">Password</p>
<p class="value">●●●●●●●●</p> <!-- Should remain hardcoded -->
</li>
</ul>
<p>After changing your password, you will be signed out from all devices.</p>
</div>
<div class="setting-card sign-in-history">
<h2 class="header">Sign in history</h2>
<ul class="setting-list">
{{#each account.devices }}
<li>
<p class="label">{{this.device_attributes.name}}</p>
<p class="value">{{this.device_attributes.created_date}}</p>
</li>
{{/each}}
</ul>
<a href="/account/sign-in-history">
<button class="button secondary">View full sign in history</button>
</a>
</div>
<h2 class="section-header" id="other">Other settings</h2>
<div class="setting-card span-both-columns">
<h2 class="header">Newsletter</h2>
<form id="other">
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
<label for="marketing">Receive project updates via email (you can opt-out at any time)</label>
</form>
</div>
</div>
</div>
{{> footer }}
</div>
{{#if justLinked}}
<div class="account-link-notice">
<div>
<p>Account linked successfully.</p>
</div>
</div>
{{/if}}
<script>
// Selectes the server level from the user's PNID
{{ account.server_access_level }}.setAttribute('checked', true);
</script>