Initial Release
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user