Initial Release

This commit is contained in:
2026-05-24 21:58:23 -07:00
commit 0a782e0096
209 changed files with 11657 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
extends _TemplateSpecial.pug
block pageHeader
link(rel='stylesheet' href='file://styles/ui-account.css')
link(rel='stylesheet' href='file://styles/unique-oauth2.css')
meta(property='og:title' content='suzzy - Authorize App')
meta(property='og:description' content='Connect an application to your suzzy games account.')
meta(name='description' content='Connect an application to your suzzy games account.')
title suzzy - Authorize App
block pageContent
div(class='modal-wrapper modal-static')
//- Error Modal
div(id='oauth2-fail' class='oauth2-centered oauth2-container modal-container' hidden)
div(class='modal-content')
img(class='oauth2-icon' aria-hidden='true' src='/assets/images/icons/icon-cross.svg' alt='Icon for Cross')
p(class='text-header') An Error has Occurred
p(class='text-tip' id='fail-reason') Unknown Error
//- Loading Modal
div(id='oauth2-load' class='oauth2-centered oauth2-container modal-container')
div(class='oauth2-centered modal-content')
img(aria-hidden='true' src='/assets/images/icons/diagram-loading.svg' alt='Icon for Spinner' height=64)
p Loading Application
//- Content Modal
div(id='oauth2-flow' class='oauth2-container modal-container' hidden)
div(class='oauth2-content modal-content')
//- Header
div(class='divider-spacer')
div(class='oauth2-header')
div(class='oauth2-icons')
img(class='application-icon' id='image-user' onload='this.style.opacity=1')
img(aria-hidden='true' class='oauth2-diagram' src='/assets/images/icons/diagram-connection.svg' alt='Diagram for Connection')
img(class='application-icon' id='image-app' onload='this.style.opacity=1')
p(id='content-aname' class='text-header')
button(id='content-login' class='small' onclick='SignOut(false, true)')
div(class='divider-spacer')
div(class='divider-line')
//- Information
p(class='text-tip') This application will be allowed to:
div(id='content-scopes' class='scopes-wrapper text-box')
div(class='divider-line')
p(id='content-created' class='text-tip text-muted')
| • Active since
p(id='content-redirect' class='text-tip text-muted')
| • You'll be redirected to: #[br]
//- Actions
div(class='modal-actions')
p(class='modal-tooltip text-error' id='action-error')
button(class='default' id='action-cancel') Cancel
button(class='default' id='action-authorize') Authorize
block pageEscaped
script(src='/assets/scripts/login-oauth2.js')
@@ -0,0 +1,291 @@
(() => {
// Support Functions
const trimDataURL = (s, x = undefined) => s.slice(s.indexOf(',') + 1, x)
const redirectsToString = s => s.sort().join()
function PrepareTemplate(selector) {
const elem = $(selector)
elem.removeAttribute('hidden')
elem.removeAttribute('id')
elem.remove()
return elem
}
// No Applications Warning
let appCount = 0
function DisplayNoApps(newCount) {
_ActionCreate.removeAttribute('hidden')
appCount = newCount
appCount === 0
? _AlertEmpty.removeAttribute('hidden')
: _AlertEmpty.setAttribute('hidden', true)
}
// Displays Modal with Secret Key
function DisplaySecret(secretKey) {
CreateModal({
behaviour: {
title: 'Your Secret Key',
tooltip: 'Do not share this key with anyone!',
submitText: 'Close',
cancelText: '',
},
_secret: {
type: 'text',
title: '',
value: secretKey,
readonly: true,
}
})
}
// Use Application Template
const
_AlertEmpty = $('#alert-empty'),
_AlertLoad = $('#alert-load'),
_ActionCreate = $('#action-create'),
_Collection = $('#application-collection'),
_LinkTemplate = PrepareTemplate('.input-redirect'),
_ItemTemplate = PrepareTemplate('#application-template')
function DisplayApplication(a) {
let removingIcon = false
let unsavedChanges = false
const
item = _ItemTemplate.cloneNode(true),
elemStatus = $$(item, '#status-message'),
elemOptions = $$(item, '.application-options'),
elemRedirects = $$(item, '#container-redirects'),
previewIcon = $$(item, '#app-icon'),
previewName = $$(item, '#app-name'),
previewDesc = $$(item, '#app-desc'),
inputName = $$(item, '#input-name'),
inputDesc = $$(item, '#input-desc'),
inputIcon = $$(item, '#input-icon'),
inputIconFile = $$(item, '#input-icon-file'),
inputIconRemove = $$(item, '#input-icon-remove'),
inputRedirectNew = $$(item, '#input-redirect-new'),
inputSave = $$(item, '#input-save'),
inputDiscard = $$(item, '#input-discard'),
inputResetSecret = $$(item, '#input-reset'),
inputDelete = $$(item, '#input-delete'),
inputEdit = $$(item, '#input-edit')
function CreateRedirect(givenURL) {
const elem = _LinkTemplate.cloneNode(true)
const inputUrl = elem.firstChild
inputUrl.value = givenURL
inputUrl.addEventListener('change', UpdateForm)
elem.lastChild.addEventListener('click', () => {
elem.remove()
UpdateForm()
})
elemRedirects.appendChild(elem)
}
function ResetForm() {
removingIcon = false
previewName.textContent = a.name?.trim() || 'Untitled'
previewDesc.textContent = a.description?.trim() || 'No Description Provided.'
previewIcon.src = ImageURL('icons', a.id, a.icon, false, 128)
previewIcon.alt = `Icon for ${a.name}`
inputIconFile.value = null
inputDesc.value = a.description
inputName.value = a.name
elemRedirects.innerHTML = ''
a.redirects.forEach(CreateRedirect)
UpdateForm()
}
function UpdateForm() {
// New Redirect
elemRedirects.childElementCount >= 10
? inputRedirectNew.setAttribute('hidden', true)
: inputRedirectNew.removeAttribute('hidden')
// Remove Icon
!removingIcon && (a.icon !== null || inputIconFile.files.length > 0)
? inputIconRemove.removeAttribute('disabled')
: inputIconRemove.setAttribute('disabled', true)
// Save/Discard Changes
unsavedChanges =
Object.keys(CollectBody()).length &&
Array.from(elemRedirects.childNodes).every(e => e.firstChild.checkValidity())
if (unsavedChanges) {
inputDiscard.removeAttribute('disabled')
inputSave.removeAttribute('disabled')
} else {
inputDiscard.setAttribute('disabled', true)
inputSave.setAttribute('disabled', true)
}
}
function CollectBody() {
const body = {}
if ((inputName.value.trim() || a.name) !== a.name)
body['name'] = inputName.value
if (inputDesc.value.trim() !== (a.description || ''))
body['description'] = inputDesc.value
if (removingIcon && body.icon)
body['icon'] = ''
if (inputIconFile.files.length)
body['icon'] = trimDataURL(previewIcon.src)
const collectedRedirects = Array
.from(elemRedirects.childNodes)
.filter(e => e.firstChild.checkValidity())
.map(e => e.firstChild.value)
if (redirectsToString(collectedRedirects) !== redirectsToString(a.redirects)) {
body.redirects = collectedRedirects
}
return body
}
// Set/Remove Application Icon
inputIconRemove.addEventListener('click', () => {
previewIcon.src = ImageURL('icon', a.id, null)
inputIconFile.value = null
removingIcon = true
UpdateForm()
})
inputIconFile.addEventListener('change', ev => {
const file = ev.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = e => {
previewIcon.src = e.target.result
removingIcon = false
UpdateForm()
}
reader.readAsDataURL(file)
})
// Field Updating
inputName.addEventListener('input', () => previewName.textContent = inputName.value.trim() || a.name)
inputDesc.addEventListener('input', () => previewDesc.textContent = inputDesc.value.trim() || a.description)
for (const needsListener of [inputName, inputDesc]) {
needsListener.addEventListener('change', UpdateForm)
}
// Button Listeners
inputRedirectNew.addEventListener('click',
() => CreateRedirect('')
)
inputDiscard.addEventListener('click',
() => ResetForm()
)
inputIcon.addEventListener('click',
() => inputIconFile.click()
)
inputEdit.addEventListener('click', () => {
elemOptions.classList.toggle('options-hidden')
ResetForm()
})
inputSave.addEventListener('click', () => {
if (!unsavedChanges) return
const unlock = LockInteraction()
elemStatus.textContent = 'Saving Changes...'
SendRequest('PATCH', `/api/v1/users/@me/applications/${a.id}`, CollectBody(), 1)
.then(([_, patchedBody]) => {
elemStatus.textContent = 'Changes Saved!'
elemStatus.classList.remove('text-error')
a = patchedBody
unlock()
ResetForm()
})
.catch(e => {
elemStatus.textContent = e
elemStatus.classList.add('text-error')
unlock()
})
// we dont use finally block here because race condition :p
})
inputResetSecret.addEventListener('click', () => CreateModal({
behaviour: {
title: 'Reset Secret Key?',
tooltip: `${a.name} will stop functioning until you update the secret key in your code.`,
submitText: 'Reset',
onSubmit: () => new Promise(ok => {
SendRequest('DELETE', `/api/v1/users/@me/applications/${a.id}/reset`, undefined, 2)
.then(([_, body]) => {
DisplaySecret(body.secret_key)
ok()
})
.catch(ok)
})
}
}))
inputDelete.addEventListener('click', () => CreateModal({
behaviour: {
title: 'Delete Application?',
tooltip: `${a.name} will be deleted and all connections will be severed. This action cannot be undone.`,
submitText: 'Delete',
onSubmit: () => new Promise(ok => {
SendRequest('DELETE', `/api/v1/users/@me/applications/${a.id}`, undefined, 2)
.then(() => {
item.remove()
DisplayNoApps(appCount - 1)
ok()
})
.catch(ok)
})
}
}))
// Append to top
// Official Application?
if ((a.flags & 1 << 3) !== 0) {
$$(item, '#verified-message').removeAttribute('hidden')
}
$$(item, '#input-clientid').textContent += a.id
ResetForm()
_Collection.appendChild(item)
}
// Fetch Connections
SendRequest('GET', '/api/v1/users/@me/applications', null, 1)
.then(([_, body]) => {
// Display Applications
body
.sort((a, b) => new Date(b.created) - new Date(a.created))
.forEach(DisplayApplication)
DisplayNoApps(body.length)
// Bind Create Application
_ActionCreate.addEventListener('click', function ActionCreate() {
CreateModal({
behaviour: {
title: 'Create Application',
tooltip: 'Name it something cool like Phase Connect.',
onSubmit: ({ name }) => new Promise(ok => {
SendRequest('POST', '/api/v1/users/@me/applications', { name }, 1)
.then(([_, body]) => {
DisplaySecret(body.secret_key)
DisplayNoApps(appCount + 1)
DisplayApplication(body)
ok()
})
.catch(ok)
})
},
_divider: { type: 'divider-line' },
name: {
type: 'text',
required: true,
title: 'Name',
validator: function ValidateName(v) {
if (v.length > 24) return 'Application name cannot be longer than 32 characters'
if (v.length < 1) return 'Application name cannot be shorter than 1 character'
return true
}
},
})
})
})
.catch(e => {
// Display Error Message
$('#alert-error').removeAttribute('hidden')
$('#error-message').textContent = e
console.log(e)
})
.finally(() => {
// Hide Loading Symbol
_AlertLoad.setAttribute('hidden', true)
})
})()
@@ -0,0 +1,94 @@
(async () => {
const _AlertEmpty = $('#alert-empty')
const _AlertLoad = $('#alert-load')
const _Collection = $('#application-collection')
// Prepare Templates
function prepareTemplate(name) {
const elem = $(name)
elem.removeAttribute('hidden')
elem.removeAttribute('id')
elem.remove()
return elem
}
const _ItemTemplate = prepareTemplate('#application-template')
const _ScopeTemplate = prepareTemplate('#scope-template')
// Fetch Connections
SendRequest('GET', '/api/v1/users/@me/connections', null, 1)
.then(([_, body]) => {
if (body.length === 0) {
_AlertEmpty.removeAttribute('hidden')
return
}
body
.sort((a, b) => new Date(b.created) - new Date(a.created))
.forEach(connection => {
// Metadata
const { name, flags, description, id, icon } = connection.application
const Template = _ItemTemplate.cloneNode(true)
$$(Template, '.text-header').textContent = name
$$(Template, '.text-tip').textContent = description || 'No Description Provided.'
// Images
const Image = $$(Template, '.application-icon')
Image.src = ImageURL('icons', id, icon, false, 128)
Image.alt = `Icon for ${name}`
// Scopes
const ScopesParent = Template.querySelector('.scopes')
_Scopes.forEach(s => {
if ((connection.scopes & s[0]) !== 0) {
const Scope = _ScopeTemplate.cloneNode(true)
Scope.lastChild.textContent = s[1]
ScopesParent.appendChild(Scope)
}
})
// Official Application?
if ((flags & 1 << 3) !== 0) {
$$(Template, '#verified-message').removeAttribute('hidden')
}
// Buttons
const Button = $$(Template, 'button')
Button.title = `Disconnect Application ${name}`
Button.addEventListener('click', async () => {
await CreateModal({
behaviour: {
title: 'Disconnect Application?',
tooltip: 'The application ' + name + ' will be disconnected from your account.'
}
})
SendRequest('DELETE', `/api/v1/users/@me/connections/${connection.id}`, null, 2)
.then(() => {
// Remove Item from List
body.length--
Template.remove()
if (body.length === 0) {
_AlertEmpty.removeAttribute('hidden')
_AlertLoad.setAttribute('hidden', true)
return
}
})
.catch(e => {
$$(Template, '#error-message').textContent = e
})
})
// Append
_Collection.appendChild(Template)
})
})
.catch(e => {
// Display Error Message
$('#alert-error').removeAttribute('hidden')
$('#error-message').textContent = e
console.log(e)
})
.finally(() => {
// Hide Loading Symbol
_AlertLoad.setAttribute('hidden', true)
})
})()
@@ -0,0 +1,80 @@
(async () => {
const _Collection = $('#application-collection')
const _Template = $('#device-template')
_Template.removeAttribute('hidden')
_Template.removeAttribute('id')
_Template.remove()
// Fetch Sessions
SendRequest('GET', '/api/v1/users/@me/security/sessions', null, 1)
.then(([_, body]) => {
// There's at minimum one session so we
// don't need a the 'No Connections' type message here.
body.sessions
.sort((a, b) => new Date(b.last_used) - new Date(a.last_used))
.forEach(session => {
// Metadata
const Template = _Template.cloneNode(true)
const Me = (session.fingerprint === body.fingerprint)
if (Me) $$(Template, '#me').removeAttribute('hidden')
$$(Template, '#browser').textContent = session.browser
$$(Template, '#location').textContent = session.location
$$(Template, '#lastseen').textContent = (() => {
const secondsAgo = (new Date() - new Date(session.last_used)) / 1000 | 0
if (secondsAgo < 60) {
return 'just now'
} else {
const timeUnits = [
['minute', 60],
['hour', 3600],
['day', 86400],
['week', 604800],
['month', 2592000],
];
for (const [unit, divisor] of timeUnits) {
if (secondsAgo < (divisor * 60)) {
const count = Math.floor(secondsAgo / divisor)
return `${count} ${unit}${count > 1 ? 's' : ''} ago`
}
}
}
})()
// Buttons
const Button = $$(Template, '.device-action')
Button.title = `Revoke Access for '${session.browser}' from '${session.location}'`
$$(Template, '.device-action').onclick = async () => {
await CreateModal({
behaviour: {
title: 'Revoke Session?',
tooltip: 'You\'ll have to manually log back in on this device.'
}
})
SendRequest('DELETE', `/api/v1/users/@me/security/sessions/${session.id}`, null, 2)
.then(() => {
// Redirect User to Login page if revoked self.
Template.remove()
if (Me) window.location.href = '/login'
})
.catch(e => {
$$(Template, '#error-message').textContent = e
})
}
// Append
_Collection.appendChild(Template)
})
})
.catch(e => {
// Display Error Message
$('#alert-error').removeAttribute('hidden')
$('#error-message').textContent = e
console.log(e)
})
.finally(() => {
// Hide Loading Symbol
$('#alert-load').setAttribute('hidden', true)
})
})()
@@ -0,0 +1,263 @@
(() => {
const
_Profile = $('.profile-wrapper'),
_Preview = $('.profile-container'),
_Status = $('#profile-status'),
// Option Elements
iDisplayname = $('#input-displayname'),
iSubtitle = $('#input-subtitle'),
iBio = $('#input-bio'),
iAvatarFile = $('#input-avatar-file'),
iBannerFile = $('#input-banner-file'),
iBannerColor = $('#input-banner'),
iBorderColor = $('#input-border'),
iBackgroundColor = $('#input-background'),
iSubmit = $('#button-submit'),
iCancel = $('#button-cancel'),
// Preview Elements
pBanner = $('#profile-banner'),
pAvatar = $('#profile-avatar'),
pBadges = $('#profile-badges'),
pDisplayname = $('#profile-displayname'),
pUsername = $('#profile-username'),
pSubtitle = $('#profile-subtitle'),
pDivider = $('#profile-divider'),
pBio = $('#profile-bio'),
// Buttons
bAvatar = $('#button-avatar-remove'),
bBanner = $('#button-banner-remove')
// Elements that the user interacts with in real time
const _InputElements = [
iDisplayname,
iSubtitle,
iBio,
iBannerColor,
iBorderColor,
iBackgroundColor,
// These elements have async calls
// they'll update the preview when complete
// pBanner,
// pAvatar,
]
const _RecognizedBadges = [
[1 << 0, 'crown', 'The Boss'],
[1 << 1, 'star', 'V.I.P'],
[1 << 2, 'picross', 'Picross Master'],
]
SendRequest('GET', '/api/v1/users/@me', null, 1)
.then(([_, body]) => {
let removingBanner = false
let removingAvatar = false
let unsavedChanges = false
const hexToDec = e => parseInt(e.value.slice(1), 16)
const trimDataURL = (s, x = undefined) => s.slice(s.indexOf(',') + 1, x)
const decToHex = (e, x) => '#' + (e?.toString(16).padStart('6', 0) || x)
function FormReset() {
const colorBanner = decToHex(body.accent_banner, '1d1e2e')
const colorBorder = decToHex(body.accent_border, '282a3f')
const colorBackground = decToHex(body.accent_background, '333550')
// Reset Options
iDisplayname.value = body.displayname
iSubtitle.value = body.subtitle
iBio.value = body.biography
iBannerColor.value = colorBanner
iBorderColor.value = colorBorder
iBackgroundColor.value = colorBackground
iAvatarFile.value = null
iBannerFile.value = null
// Reset Preview
pDisplayname.textContent = body.displayname
pUsername.textContent = '@' + body.username
pSubtitle.textContent = body.subtitle
pBio.textContent = body.biography
body.biography
? pDivider.removeAttribute('hidden')
: pDivider.setAttribute('hidden', true)
pAvatar.src = ImageURL('avatars', body.id, body.avatar, true, 128)
pBanner.style.backgroundImage = body.banner
? `url('${ImageURL('banners', body.id, body.banner, true, 512)}')`
: ''
_Profile.style.setProperty('--accent-banner', colorBanner)
_Profile.style.setProperty('--accent-border', colorBorder)
_Profile.style.setProperty('--accent-background', colorBackground)
iBackgroundColor.oninput()
// Disable Buttons
removingAvatar = false
removingBanner = false
FormUpdated()
}
function FormUpdated() {
unsavedChanges = (Object.entries(CollectBody()).length > 0)
if (unsavedChanges) {
iCancel.removeAttribute('disabled')
iSubmit.removeAttribute('disabled')
} else {
iCancel.setAttribute('disabled', true)
iSubmit.setAttribute('disabled', true)
}
!removingBanner && (body.banner !== null || iBannerFile.files.length > 0)
? bBanner.removeAttribute('disabled')
: bBanner.setAttribute('disabled', true)
!removingAvatar && (body.avatar !== null || iAvatarFile.files.length > 0)
? bAvatar.removeAttribute('disabled')
: bAvatar.setAttribute('disabled', true)
}
function CollectBody() {
const data = {}
// Collect Textboxes
if ((iDisplayname.value.trim() || body.displayname) !== body.displayname)
data['displayname'] = iDisplayname.value
if (iBio.value.trim() !== (body.biography || ''))
data['biography'] = iBio.value
if (iSubtitle.value.trim() !== (body.subtitle || ''))
data['subtitle'] = iSubtitle.value
// Collect Colors
const decBannerColor = hexToDec(iBannerColor)
if (decBannerColor !== (body.accent_banner || 1908270))
data['accent_banner'] = decBannerColor
const decBorderColor = hexToDec(iBorderColor)
if (decBorderColor !== (body.accent_border || 2632255))
data['accent_border'] = decBorderColor
const decBackgroundColor = hexToDec(iBackgroundColor)
if (decBackgroundColor !== (body.accent_background || 3355984))
data['accent_background'] = decBackgroundColor
// Collect Images
if (removingAvatar && body.avatar) data['avatar'] = ''
if (removingBanner && body.banner) data['banner'] = ''
if (iAvatarFile.files.length)
data['avatar'] = trimDataURL(pAvatar.src)
if (iBannerFile.files.length)
data['banner'] = trimDataURL(pBanner.style.backgroundImage, -2)
console.log(data)
return data
}
// Templating
_InputElements.forEach(e => e.addEventListener('change', FormUpdated))
_RecognizedBadges.forEach(b => {
if ((body.public_flags & b[0]) !== 0) {
const img = document.createElement('img')
img.src = `/assets/images/icons/badge-${b[1]}.svg`
img.title = b[2]
img.alt = b[2]
pBadges.appendChild(img)
}
})
// Section: About Me
iDisplayname.oninput = () => pDisplayname.textContent = (iDisplayname.value || body.displayname).trim()
iSubtitle.oninput = () => pSubtitle.textContent = iSubtitle.value.trim()
iBio.oninput = () => {
const content = (iBio.value || '')
pBio.textContent = content.trim()
content.length > 0
? pDivider.removeAttribute('hidden')
: pDivider.setAttribute('hidden', true)
}
// Section: Images
$('#button-avatar').onclick = () => iAvatarFile.click()
$('#button-banner').onclick = () => iBannerFile.click()
bAvatar.onclick = () => {
pAvatar.src = ImageURL('avatars', body.id, null)
iAvatarFile.value = null
removingAvatar = true
FormUpdated()
}
bBanner.onclick = () => {
pBanner.style.backgroundImage = null
iBannerFile.value = null
removingBanner = true
FormUpdated()
}
iAvatarFile.onchange = (ev) => {
const file = ev.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = e => {
pAvatar.src = e.target.result
removingAvatar = false
FormUpdated()
}
reader.readAsDataURL(file)
}
iBannerFile.onchange = (ev) => {
const file = ev.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = e => {
pBanner.style.backgroundImage = `url(${e.target.result})`
removingBanner = false
FormUpdated()
}
reader.readAsDataURL(file)
}
// Section: Accents
iBannerColor.oninput = () => _Profile.style.setProperty('--accent-banner', iBannerColor.value)
iBorderColor.oninput = () => _Profile.style.setProperty('--accent-border', iBorderColor.value)
iBackgroundColor.oninput = () => {
let hexColor = iBackgroundColor.value
_Profile.style.setProperty('--accent-background', hexColor)
// Check Luminance of Background Color
hexColor = hexColor.replace('#', '')
var r = parseInt(hexColor.substr(0, 2), 16)
var g = parseInt(hexColor.substr(2, 2), 16)
var b = parseInt(hexColor.substr(4, 2), 16)
if (((0.299 * r) + (0.587 * g) + (0.114 * b)) / 255 > 0.85) {
_Preview.classList.add('profile-dark')
} else {
_Preview.classList.remove('profile-dark')
}
}
// Section: Actions
iCancel.onclick = () => FormReset()
iSubmit.onclick = () => {
if (!unsavedChanges) return
const unlock = LockInteraction()
_Status.textContent = 'Saving Changes...'
SendRequest('PATCH', '/api/v1/users/@me', CollectBody(), 1)
.then(([_, patchedBody]) => {
_Status.textContent = 'Profile Saved!'
_Status.classList.remove('text-error')
localStorage.removeItem('cache_home_me') // Used by page-home.js
body = patchedBody
unlock()
FormReset()
})
.catch(e => {
_Status.textContent = e
_Status.classList.add('text-error')
unlock()
})
// we dont use finally block here because race condition :p
}
// Display Profile
_Profile.removeAttribute('hidden')
FormReset()
})
.catch(e => {
// Display Error Message
$('#alert-error').removeAttribute('hidden')
$('#error-message').textContent = e
console.log(e)
})
.finally(() => {
// Hide Loading Symbol
$('#alert-load').setAttribute('hidden', true)
})
})()
@@ -0,0 +1,281 @@
(() => {
const
elemPreviewEmail = $('#preview-email'),
actionEmailReveal = $('#email-reveal'),
actionEmailEdit = $('#email-edit'),
actionEmailResend = $('#email-resend'),
actionPassChange = $('#password-change'),
actionMFASetup = $('#mfa-setup-start'),
actionMFASetupRemove = $('#mfa-setup-remove'),
actionMFARecoveryView = $('#mfa-recovery-view'),
actionMFARecoveryRegen = $('#mfa-recovery-regen'),
actionDangerDeleteAccount = $('#danger-deleteaccount'),
errorMFA = $('#error-mfa'),
errorEmail = $('#error-email'),
errorDanger = $('#error-danger')
SendRequest('GET', '/api/v1/users/@me', null, 1)
.then(([_, body]) => {
// Email
let emailHidden = false
let emailTemplate = elemPreviewEmail.textContent
actionEmailReveal.onclick = () => {
let newPreview
if (emailHidden) {
newPreview = body.email
actionEmailReveal.textContent = 'Hide Email Address'
} else {
const usernameLength = body.email.indexOf('@')
const censoredSegment = ''.padStart(usernameLength > 12 ? 12 : usernameLength, '*')
const hostSegment = body.email.slice(usernameLength)
newPreview = censoredSegment + hostSegment
actionEmailReveal.textContent = 'Reveal Email Address'
}
elemPreviewEmail.textContent = (emailTemplate + newPreview)
emailHidden = !emailHidden
}
actionEmailReveal.onclick()
if (!body.verified) {
actionEmailResend.removeAttribute('hidden')
actionEmailResend.onclick = () => {
const unlock = LockInteraction()
SendRequest('POST', '/api/v1/users/@me/security/email', null, 1)
.then(() => CreateModal({
behaviour: {
cancelText: '',
submitText: 'Close',
title: 'Verification Email Sent',
tooltip: 'An email to verify this email address has been sent. Please check your spam or inbox.',
}
}))
.catch(e => errorEmail.textContent = e)
.finally(unlock)
}
}
actionEmailEdit.onclick = () => {
CreateModal({
behaviour: {
title: 'Update Email',
tooltip: 'Changing your email address will mark it as unverified.',
onSubmit: ({ email }) => new Promise(ok => {
SendRequest('PATCH', '/api/v1/users/@me/security/email', { email }, 2)
.then(() => window.location.reload())
.catch(ok)
})
},
'_divider': { type: 'divider-line' },
'email': {
type: 'text',
required: true,
title: 'Email Address',
validator: TestEmailSuite,
}
})
}
// Password
actionPassChange.onclick = () => {
CreateModal({
behaviour: {
title: 'Change Password',
tooltip: 'Enter your current password and new password.',
onSubmit: ({ old_password, new_password }) => new Promise(ok => {
SendRequest(
'PATCH', '/api/v1/users/@me/security/password',
{ old_password, new_password }, 1
)
.then(() => ok())
.catch(ok)
})
},
'_link': {
type: 'link',
title: 'Forgot Password?',
href: '/password-reset',
},
'_divider': { type: 'divider-line' },
'old_password': {
type: 'password',
required: true,
title: 'Old Password',
},
'new_password': {
type: 'password',
required: true,
title: 'New Password',
validator: (v, { new_password_again }) => {
if (v !== new_password_again) return 'Passwords do not Match'
return TestPasswordSuite(v)
},
},
'new_password_again': {
type: 'password',
required: true,
title: 'New Password Again',
validator: (v, { new_password }) => {
if (v !== new_password) return 'Passwords do not Match'
return TestPasswordSuite(v)
},
},
})
}
// Multi-Factor
function RecoveryModal(codes) {
return CreateModal({
behaviour: {
title: 'Recovery Codes',
tooltip:
'Keep these recovery codes safe! ' +
'You\'ll lose access to your account if both recovery codes and your authenticator app are lost.',
submitText: 'Download',
cancelText: 'Close',
onSubmit: () => {
// Generate Text
const filename = `Recovery Codes for ${body.username}.txt`
const filedata =
`Recovery Codes for ${body.username} (${body.email})\n` +
`Generated on ${new Date().toLocaleString()}\n` +
`------------\n` +
codes.map(c => `> ${c}\n`).join('');
// Fake Download Element
const a = document.createElement('a')
a.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(filedata))
a.setAttribute('download', filename)
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
return ' '
}
},
'_codes': {
type: 'recovery-codes',
codes: codes
}
})
}
if (body.mfa_enabled) {
actionMFASetupRemove.removeAttribute('hidden')
actionMFASetupRemove.onclick = async () => {
await CreateModal({
behaviour: {
title: 'Remove Authenticator App?',
tooltip: 'This will disable MFA on your account.',
}
})
const unlock = LockInteraction()
SendRequest('DELETE', '/api/v1/users/@me/security/mfa/setup', null, 2)
.then(() => window.location.reload())
.catch(e => errorMFA.textContent = e)
.finally(unlock)
}
actionMFARecoveryView.removeAttribute('hidden')
actionMFARecoveryView.onclick = () => {
const unlock = LockInteraction()
SendRequest('GET', '/api/v1/users/@me/security/mfa/codes', null, 2)
.then(([_, codes]) => RecoveryModal(codes))
.catch(e => errorMFA.textContent = e)
.finally(unlock)
}
actionMFARecoveryRegen.removeAttribute('hidden')
actionMFARecoveryRegen.onclick = async () => {
await CreateModal({
behaviour: {
title: 'Reset Recovery Codes?',
tooltip: 'This will void the previously generated codes.',
}
})
const unlock = LockInteraction()
SendRequest('DELETE', '/api/v1/users/@me/security/mfa/codes', null, 2)
.then(([_, codes]) => RecoveryModal(codes))
.catch(e => errorMFA.textContent = e)
.finally(unlock)
}
} else {
actionMFASetup.removeAttribute('hidden')
actionMFASetup.onclick = () => {
const unlock = LockInteraction()
SendRequest('GET', '/api/v1/users/@me/security/mfa/setup', null, 1)
.then(([_, mfa]) => {
CreateModal({
behaviour: {
title: 'Setup Authenticator App',
tooltip: 'Scan the QR code using your Authenticator App and enter the generated passcode.',
onSubmit: ({ passcode }) => new Promise(ok => {
const unlock = LockInteraction()
SendRequest('POST', '/api/v1/users/@me/security/mfa/setup', { passcode }, 1)
.then((/* 204 No Content */) => {
RecoveryModal(mfa.recovery_codes)
.finally(() => window.location.reload())
ok()
})
.catch(ok)
.finally(unlock)
})
},
'_spacer1': { type: 'divider-line' },
'_qrcode': {
type: 'qr-code',
src: mfa.image,
},
'_spacer2': { type: 'divider-line' },
'_secret': {
type: 'text',
title: 'Can\'t Scan the QR Code? Use this secret key:',
value: mfa.secret,
readonly: true,
},
passcode: {
required: true,
type: 'password',
title: 'Passcode',
autocomplete: 'one-time-code',
placeholder: '123456',
validator: v => {
if (!/[0-9]{6}/.test(v)) return 'Invalid Passcode'
return true
}
},
})
})
.catch(e => errorMFA.textContent = e)
.finally(unlock)
}
}
// Danger Zone
actionDangerDeleteAccount.onclick = async () => {
// Prompt Modal
await CreateModal({
behaviour: {
title: 'Delete Account?',
tooltip:
'This will start a countdown where afterwards your account and all of its data will be deleted. ' +
'More details will be sent to your email address.',
}
})
const unlock = LockInteraction()
SendRequest('DELETE', '/api/v1/users/@me', null, 2)
.then(() => SignOut())
.catch(e => errorDanger.textContent = e)
.finally(unlock)
}
// Display Menu
$('#security-wrapper').removeAttribute('hidden')
})
.catch(e => {
// Display Error Message
$('#alert-error').removeAttribute('hidden')
$('#error-message').textContent = e
console.log(e)
})
.finally(() => {
// Hide Loading Symbol
$('#alert-load').setAttribute('hidden', true)
})
})()
@@ -0,0 +1,67 @@
let x = {
static: true,
submitText: 'Log In',
cancelText: '',
onSubmit: ({ email, password }) => new Promise(submitResult => {
const unlock = LockInteraction()
SendRequest('POST', '/api/v1/auth/login', { email, password }, 0, false)
.then(([resp, body]) => {
if (!resp.ok) {
// Display Custom Modal
switch (body.code) {
case 2000: // ERROR_MFA_EMAIL_SENT
return CreateModal({
behaviour: {
cancelText: '',
title: 'Verify Login from a New Device',
tooltip: 'Check your email for a verification link to authorize access from this device.'
},
})
.catch(() => { /** Silence Modal Closing */ })
case 2001: // ERROR_MFA_PASSCODE_REQUIRED
return CreateModal({
'behaviour': {
title: 'Passcode Required',
tooltip: 'Please enter your Authenticator Passcode or Recovery Code to log in.',
onSubmit: ({ passcode }) => new Promise(mfaResult => {
SendRequest('POST', '/api/v1/auth/login', { email, password, passcode }, 0)
.then(([_, body]) => {
// Store Credentials
localStorage.setItem('auth_token', body.token)
localStorage.setItem('auth_expires', Date.now() + (body.expires_in * 1000))
FollowRedirect('/account/profile')
})
.catch(mfaResult)
})
},
'_spacer': {
type: 'divider-line'
},
'passcode': {
required: true,
type: 'password',
title: 'Passcode',
autocomplete: 'one-time-code',
placeholder: '123456',
validator: v => {
if (!/([0-9]{6}|[A-z0-9]{8})/.test(v))
return 'Invalid Passcode'
return true
}
}
})
.catch(() => { /** Silence Modal Closing */ })
default:
return submitResult(body.error || body)
}
} else {
// Store Credentials
localStorage.setItem('auth_token', body.token)
localStorage.setItem('auth_expires', Date.now() + (body.expires_in * 1000))
FollowRedirect('/account/profile')
}
})
.catch(submitResult)
.finally(unlock)
}),
}
@@ -0,0 +1,78 @@
(() => {
// Fetch Application and Profile
const href = window.location.href
const query = href.slice(href.indexOf('?') + 1)
SendRequest('GET', '/api/v1/oauth2/authorize', query, 1)
.then(([_, body]) => {
const
me = body.user,
app = body.application,
ScopesParent = $('#content-scopes'),
imageUser = $('#image-user'),
imageApp = $('#image-app')
// Set Avatars
imageUser.src = ImageURL('avatars', me.id, me.avatar, false, 128)
imageApp.src = ImageURL('icons', app.id, app.icon, false, 128)
imageUser.alt = `Avatar for '${me.username}'`
imageApp.alt = `Icon for Application '${app.name}'`
// Fill Template
$('#content-login').textContent = `Logged in as ${me.username}, not you?`
$('#content-aname').textContent = `Connect ${app.name}?`
$('#content-redirect').textContent += body.redirect
$('#content-created').textContent += (() => {
const t = new Date(app.created)
const m = ['January', 'Feburary', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][t.getMonth()]
const d = t.getDate()
const y = t.getFullYear()
return `${m} ${d}${d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'}, ${y}`
})()
_Scopes.forEach(s => {
if ((body.scopes & s[0]) !== 0) {
const div = document.createElement('div')
div.classList.add('scope')
const dot = document.createElement('div')
dot.classList.add('dot-green')
const txt = document.createElement('p')
txt.textContent = s[1]
div.append(dot, txt)
ScopesParent.appendChild(div)
}
})
// Cancel Request, go to application with error...
$('#action-cancel').onclick = (ev) => {
if (!ev.isTrusted) return
const url = new URL(body.redirect)
url.searchParams.set('error', 'access_denied')
url.searchParams.set('error_description', 'User Declined')
window.location.assign(url.toString())
}
// Complete Request, go to application with code...
const actionError = $('#action-error')
$('#action-authorize').onclick = (ev) => {
if (!ev.isTrusted) return
const unlock = LockInteraction(true)
actionError.textContent = ''
SendRequest('POST', '/api/v1/oauth2/authorize', query, 1)
.then(([_, body]) => window.location.assign(body.location))
.catch(e => {
actionError.textContent = e.error || e
console.log(e)
})
.finally(() => unlock())
}
$('#oauth2-flow').removeAttribute('hidden')
})
.catch(e => {
$('#oauth2-fail').removeAttribute('hidden')
$('#fail-reason').textContent = e.error || e
console.log(e)
})
.finally(() => {
$('#oauth2-load').setAttribute('hidden', true)
})
})()
@@ -0,0 +1,389 @@
/* Collections */
div.collection-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
div.collection-item {
width: 100%;
height: fit-content;
border-radius: 8px;
overflow: hidden;
background-color: var(--background-layer-4);
border: 2px solid var(--background-layer-1);
box-sizing: border-box;
padding: 16px;
gap: 8px;
}
div.text-box {
background-color: var(--background-layer-5);
box-sizing: border-box;
border-radius: 8px;
padding: 8px;
}
div.collection-message {
width: 100%;
padding: 32px;
text-align: center;
box-sizing: border-box;
}
div.collection-message img,
div.collection-message i {
fill: var(--background-layer-0);
height: 48px;
width: 48px;
}
/* Page: Devices */
div.item-device {
display: flex;
align-items: center;
}
div.device-icon {
min-height: 72px;
min-width: 72px;
padding: 8px;
border-radius: 100%;
box-sizing: border-box;
background-color: var(--background-layer-4);
border: 2px solid var(--background-layer-1);
display: flex;
align-items: center;
justify-content: center;
}
div.device-icon img {
fill: var(--background-layer-0);
height: 40px;
width: 40px;
}
div.device-info {
flex-basis: 100%;
}
div.info-alongside {
display: flex;
flex-wrap: wrap;
align-items: center;
overflow: hidden;
gap: 8px;
}
button.device-action {
fill: var(--background-layer-1);
height: 24px;
width: 24px;
background-color: transparent;
cursor: pointer;
border: none;
transition: var(--transition-time) ease-in-out fill;
}
button.device-action:hover,
button.device-action:focus-visible {
fill: var(--text-error-1);
}
/* Page: Connections */
div.application-header {
display: flex;
align-items: self-start;
justify-content: space-between;
gap: 8px;
}
img.application-icon {
border-radius: 100%;
box-sizing: border-box;
border: 4px solid var(--background-layer-1);
background-color: var(--background-layer-4);
min-height: 64px;
max-height: 64px;
min-width: 64px;
max-width: 64px;
opacity: 0;
background-size: contain;
transition: var(--transition-time) ease-in-out opacity;
}
div.application-info {
flex-basis: 100%;
overflow: hidden;
}
div.application-extended {
display: grid;
gap: 8px;
transition: var(--transition-time) ease-in-out all;
height: -8px;
}
div.scopes-wrapper {
display: grid;
}
div.scope {
display: flex;
gap: 4px;
align-items: center;
}
div.dot-green {
width: 8px;
height: 8px;
border-radius: 100%;
background-color: #42f495;
}
/* Page: Profile */
div.profile-wrapper {
display: flex;
}
div.profile-options,
div.profile-container {
flex-basis: 50%;
box-sizing: border-box;
padding: 4px;
}
div.profile-options {
display: grid;
gap: 8px;
}
div.widget-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: flex-start
}
div.widget-color {
display: grid;
place-items: center;
}
input[type='color']::-webkit-color-swatch-wrapper {
padding: 0;
}
input.color-preview {
cursor: pointer;
border-radius: 8px;
background-color: white;
height: 48px;
width: 96px;
border: 2px solid var(--background-layer-2);
transition: var(--transition-time) ease-in-out all;
}
div.profile-container {
min-width: 350px;
height: fit-content;
background-color: var(--accent-background);
border: 4px solid var(--accent-border);
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
margin: 16px;
padding: 0;
}
#profile-banner {
width: 100%;
height: 128px;
border-bottom: 4px solid var(--accent-border);
background-color: var(--accent-banner);
background-position: center;
background-size: cover;
}
div.profile-header {
display: flex;
justify-content: space-between;
box-sizing: border-box;
padding: 8px 8px 0 8px;
}
#profile-avatar {
width: 96px;
height: 96px;
margin-top: -60px;
border-radius: 100%;
border: 4px solid var(--accent-border);
background-color: var(--accent-banner);
object-fit: cover;
}
#profile-badges {
height: fit-content;
width: fit-content;
display: flex;
flex-wrap: wrap;
background-color: rgb(0, 0, 0, 0.4);
border-radius: 8px;
padding: 8px;
gap: 4px;
}
#profile-badges:empty {
display: none;
}
#profile-badges img {
height: 20px;
width: 20px;
}
div.profile-about {
background-color: rgba(0, 0, 0, 0.4);
margin: 8px;
padding: 8px;
border-radius: 8px;
box-sizing: border-box;
}
div.profile-about p,
div.profile-about pre {
transition: var(--transition-time) ease-in-out color;
max-height: 300px;
overflow: hidden;
text-wrap: balance;
}
div.profile-dark p,
div.profile-dark pre {
color: var(--text-layer-2);
}
#profile-displayname {
font-size: large;
}
#profile-username {
font-size: small;
opacity: 0.80;
}
#profile-divider {
background-color: var(--accent-border);
opacity: 0.5;
}
/* Page: Applications */
div.application-options {
display: grid;
gap: 8px;
}
div.options-hidden {
height: 0;
overflow: hidden;
}
div.input-redirect {
display: flex;
gap: 8px;
}
div.input-redirect button {
min-width: fit-content;
height: 100%;
}
div#container-redirects {
display: grid;
gap: 8px;
}
/* Mobile Styling */
@media (max-width: 1024px) {
div.main-content {
border-right: none;
}
}
@media (max-width: 640px) {
div.main-content {
border: none;
background-color: transparent;
}
div.main-wrapper {
display: block;
}
div.main-wrapper div.divider-line {
display: block !important;
}
a.navigation-link {
font-size: large;
}
div.main-aside {
width: 100%;
max-width: 100%;
}
div.main-content {
width: 100%;
height: fit-content;
min-height: 100vh;
max-width: 100%;
}
div.collection-item {
background-color: var(--background-layer-4);
border: 2px solid var(--background-layer-3);
}
div.device-icon {
background-color: var(--background-layer-4);
border: 2px solid var(--background-layer-1);
}
/* Page: Connections */
div.application-header {
display: block;
}
div.application-header button {
margin: 8px 0;
width: 100%;
}
div.text-box {
background-color: var(--background-layer-3);
}
/* Page: Devices */
button.device-action {
height: 40px;
width: 40px;
}
div.info-alongside {
text-wrap: balance;
}
/* Page: Profile */
div.profile-wrapper {
display: block;
}
div.profile-container {
min-width: 0px;
margin: 32px 0 0 0;
}
}
@@ -0,0 +1,73 @@
//- redo with connection to profiles database
extends _Template.pug
block pageStyles
link(rel='stylesheet' href=`/assets/styles/ui-public-profile.css?v=${cachev}`)
block pageScripts
script(src=`/assets/scripts/public-profile.js?v=${cachev}`)
block pageHeader
meta(property='og:image' content='https://suzzygames.com/assets/images/og-logo.png')
meta(property='og:title' content=`profile - ${username}`)
meta(property='og:description' content=`View ${username}'s profile on suzzy games`)
meta(name='description' content=`View ${username}'s profile on suzzy games`)
title profile - #{username}
block pageContent
noscript
style.
noscript {
display: grid;
box-sizing: border-box;
place-items: center;
align-content: center;
}
#alert-load {
display: none
}
div(class='collection-message')
img(aria-hidden='true' src='/assets/images/icons/icon-cross.svg' alt='Icon for Cross')
p This page requires JavaScript to be enabled.
div(class='collection-message' id='alert-load')
img(src='/assets/images/icons/diagram-loading.svg' alt='Icon for Spinner')
p Loading Profile
div(class='collection-message' id='alert-error' hidden)
img(src='/assets/images/icons/icon-cross.svg' alt='Icon for Cross')
p(id='alert-error-message') An Error has Occurred
div(class='main-wrapper')
//- Profile
div(class='main-aside' hidden)
div(class='profile-container')
div(id='profile-banner')
div(class='profile-header')
img(id='profile-avatar' alt='My Avatar')
div(id='profile-badges')
div(class='profile-about text-nopad')
div(class='info-alongside')
p(id='profile-displayname')
p(id='profile-username')
p(id='profile-subtitle' class='text-tip')
div(class='divider-line' id='profile-divider' hidden)
pre(id='profile-bio' class='text-tip')
//- Categories
div(class='divider-spacer')
p(class='text-header') Categories
button(class='category category-active')
img(alt='Icon for Blog Post' src='/assets/blog/category-any.png')
p(class='category-name') None
p(class='category-count text-muted') (x)
//- button(class='category' id='category-posts')
//- img(alt='Icon for Blog Post' src='/assets/blog/category-news.png')
//- p(class='category-name') Blog Posts
//- p(class='category-count text-muted') (1)
//- button(class='category' id='category-friends')
//- img(alt='Icon for Blog Post' src='/assets/blog/category-friends.png')
//- p(class='category-name') Friends
//- p(class='category-count text-muted') (0)
div(class='divider-dashed' hidden)
//- Content
div(class='main-content' role='main' hidden)
div(class='collection-message')
img(src='/assets/images/icons/icon-filter.svg' alt='Icon for Filter')
p(class='text-muted') Please select a category