diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index 70af952bd..9091dc0d1 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -8,7 +8,7 @@ import yaml from 'js-yaml'; import { Context } from '../context'; import { Logger } from '../logger'; import { PRIV } from '../model/builtin'; -import { unwrapExports } from '../utils'; +import { isClass, unwrapExports } from '../utils'; const logger = new Logger('common'); @@ -52,7 +52,8 @@ export async function builtinModel(ctx: Context) { const models = await fs.readdir(modelDir); for (const t of models.filter((i) => i.endsWith('.ts'))) { const q = path.resolve(modelDir, t); - if ('apply' in require(q)) ctx.loader.reloadPlugin(q, ''); + const module = require(q); + if ('apply' in module || isClass(unwrapExports(module))) ctx.loader.reloadPlugin(q, ''); } } diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index a96e9714d..9ccaba54b 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -193,6 +193,7 @@ class HomeSecurityHandler extends Handler { this.translate('geoip_locale'), ); } + const relations = await this.ctx.oauth.list(this.user._id); this.response.template = 'home_security.html'; this.response.body = { sudoUid: this.session.sudoUid || null, @@ -202,6 +203,7 @@ class HomeSecurityHandler extends Handler { 'authenticatorAttachment', 'regat', 'fmt', ])), geoipProvider: this.ctx.geoip?.provider, + relations, }; } @@ -250,6 +252,13 @@ class HomeSecurityHandler extends Handler { this.response.template = 'user_changemail_mail_sent.html'; } + @param('platform', Types.String) + async postLinkAccount({ }, platform: string) { + if (!global.Hydro.module.oauth[platform]) throw new ValidationError('platform'); + this.session.oauthBind = platform; + await global.Hydro.module.oauth[platform].get.call(this); + } + @param('tokenDigest', Types.String) async postDeleteToken({ }, tokenDigest: string) { const sessions = await token.getSessionListByUid(this.user._id); @@ -609,7 +618,7 @@ class HomeMessagesConnectionHandler extends ConnectionHandler { } } -export const inject = { geoip: { required: false } }; +export const inject = { geoip: { required: false }, oauth: {} }; export function apply(ctx: Context) { ctx.Route('homepage', '/', HomeHandler); ctx.Route('home_security', '/home/security', HomeSecurityHandler, PRIV.PRIV_USER_PROFILE); diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 6b351369f..7bfef8844 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -4,7 +4,7 @@ import moment from 'moment-timezone'; import type { Binary } from 'mongodb'; import type { Context } from '../context'; import { - AuthOperationError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError, + AuthOperationError, BadRequestError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError, SystemError, UserAlreadyExistError, UserFacingError, UserNotFoundError, ValidationError, VerifyPasswordError, } from '../error'; @@ -16,7 +16,6 @@ import BlackListModel from '../model/blacklist'; import { PERM, PRIV, STATUS } from '../model/builtin'; import * as ContestModel from '../model/contest'; import domain from '../model/domain'; -import oauth from '../model/oauth'; import * as oplog from '../model/oplog'; import problem, { ProblemDoc } from '../model/problem'; import ScheduleModel from '../model/schedule'; @@ -28,6 +27,18 @@ import { Handler, param, post, Types, } from '../service/server'; +async function successfulAuth(this: Handler, udoc: User) { + await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip }); + this.context.HydroContext.user = udoc; + this.session.viewLang = ''; + this.session.uid = udoc._id; + this.session.sudo = null; + this.session.sudoUid = null; + this.session.scope = PERM.PERM_ALL.toString(); + this.session.oauthBind = null; + this.session.recreate = true; +} + class UserLoginHandler extends Handler { noCheckPermView = true; async prepare() { @@ -74,15 +85,9 @@ class UserLoginHandler extends Handler { } else throw new ValidationError('2FA', 'Authn'); } await udoc.checkPassword(password); - await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip }); if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname, udoc.banReason); - this.context.HydroContext.user = udoc; - this.session.viewLang = ''; - this.session.uid = udoc._id; - this.session.sudo = null; - this.session.scope = PERM.PERM_ALL.toString(); + await successfulAuth.call(this, udoc); this.session.save = rememberme; - this.session.recreate = true; this.response.redirect = redirect || ((this.request.referer || '/login').endsWith('/login') ? this.url('homepage') : this.request.referer); } @@ -193,15 +198,12 @@ class UserLogoutHandler extends Handler { } async post({ domainId }) { - this.context.HydroContext.user = await user.getById(domainId, 0); - this.session.uid = 0; - this.session.sudo = null; - this.session.sudoUid = null; - this.session.scope = PERM.PERM_ALL.toString(); + await successfulAuth.call(this, await user.getById(domainId, 0)); this.response.redirect = '/'; } } +// rename to RegisterSendMailHandler export class UserRegisterHandler extends Handler { noCheckPermView = true; async prepare() { @@ -225,7 +227,15 @@ export class UserRegisterHandler extends Handler { const t = await token.add( token.TYPE_REGISTRATION, system.get('session.unsaved_expire_seconds'), - { mail, redirect: this.domain.registerRedirect }, + { + mail, + redirect: this.domain.registerRedirect, + identity: { + provider: 'mail', + platform: 'mail', + id: mail, + }, + }, ); const prefix = this.domain.host ? `${this.domain.host instanceof Array ? this.domain.host[0] : this.domain.host}` @@ -249,7 +259,7 @@ class UserRegisterWithCodeHandler extends Handler { @param('code', Types.String) async prepare({ }, code: string) { this.tdoc = await token.get(code, token.TYPE_REGISTRATION); - if (!this.tdoc || (!this.tdoc.mail && !this.tdoc.phone)) { + if (!this.tdoc?.identity) { // prevent brute forcing tokens await this.limitRate('user_register_with_code', 60, 5); throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_REGISTRATION], code); @@ -269,28 +279,22 @@ class UserRegisterWithCodeHandler extends Handler { domainId: string, password: string, verify: string, uname = '', code: string, ) { - if (this.tdoc.oauth?.[0] && global.Hydro.module.oauth[this.tdoc.oauth[0]].lockUsername) { - uname = this.tdoc.username; + if (global.Hydro.module.oauth[this.tdoc.identity.provider].lockUsername) { + uname = this.tdoc.identity.username; } if (!Types.Username[1](uname)) throw new ValidationError('uname'); if (password !== verify) throw new VerifyPasswordError(); - if (this.tdoc.phone) this.tdoc.mail = `${String.random(12)}@hydro.local`; - const uid = await user.create(this.tdoc.mail, uname, password, undefined, this.request.ip); + const randomEmail = `${String.random(12)}@invalid.local`; // some random email to remove in the future + const uid = await user.create(this.tdoc.mail || randomEmail, uname, password, undefined, this.request.ip); await token.del(code, token.TYPE_REGISTRATION); const [id, mailDomain] = this.tdoc.mail.split('@'); const $set: any = this.tdoc.set || {}; - if (this.tdoc.phone) $set.phone = this.tdoc.phone; if (mailDomain === 'qq.com' && !Number.isNaN(+id)) $set.avatar = `qq:${id}`; if (this.session.viewLang) $set.viewLang = this.session.viewLang; if (Object.keys($set).length) await user.setById(uid, $set); if (Object.keys(this.tdoc.setInDomain || {}).length) await domain.setUserInDomain(domainId, uid, this.tdoc.setInDomain); - if (this.tdoc.oauth) await oauth.set(this.tdoc.oauth[1], uid); - this.context.HydroContext.user = await user.getById(domainId, uid); - this.session.viewLang = ''; - this.session.uid = uid; - this.session.sudoUid = null; - this.session.scope = PERM.PERM_ALL.toString(); - this.session.recreate = true; + await this.ctx.oauth.set(this.tdoc.identity.platform, this.tdoc.identity.id, uid); + await successfulAuth.call(this, await user.getById(domainId, uid)); this.response.redirect = this.tdoc.redirect || this.url('home_settings', { category: 'preference' }); } } @@ -434,56 +438,57 @@ class OauthCallbackHandler extends Handler { async get(args: any) { if (!global.Hydro.module.oauth[args.type]) throw new UserFacingError('Oauth type'); const r = await global.Hydro.module.oauth[args.type].callback.call(this, args); - const uid = await oauth.get(r._id); - if (uid) { - await user.setById(uid, { loginat: new Date(), loginip: this.request.ip }); - this.session.uid = uid; - this.session.scope = PERM.PERM_ALL.toString(); - this.response.redirect = '/'; - } else { - if (r.email) { - const udoc = await user.getByEmail('system', r.email); - if (udoc) { - await oauth.set(r._id, udoc._id); - await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip }); - this.session.uid = udoc._id; - this.session.scope = PERM.PERM_ALL.toString(); - this.session.recreate = true; - this.response.redirect = '/'; - return; - } + if (this.session.oauthBind === args.type) { + delete this.session.oauthBind; + const existing = await this.ctx.oauth.get(args.type, r._id); + if (existing && existing !== this.user._id) { + throw new BadRequestError('Already binded to another account'); } - this.checkPriv(PRIV.PRIV_REGISTER_USER); - let username = ''; - r.uname ||= []; - const mailDomain = r.email.split('@')[1]; - if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain); - for (const uname of r.uname) { - // eslint-disable-next-line no-await-in-loop - const nudoc = await user.getByUname('system', uname); - if (!nudoc) { - username = uname; - break; - } - } - const set: Partial = { ...r.set, oauth: args.type }; - if (r.bio) set.bio = r.bio; - if (r.viewLang) set.viewLang = r.viewLang; - if (r.avatar) set.avatar = r.avatar; - const [t] = await token.add( - token.TYPE_REGISTRATION, - system.get('session.unsaved_expire_seconds'), - { - mail: r.email, - username, - redirect: this.domain.registerRedirect, - set, - setInDomain: r.setInDomain, - oauth: [args.type, r._id], - }, - ); - this.response.redirect = this.url('user_register_with_code', { code: t }); + this.response.redirect = '/home/security'; + if (existing !== this.user._id) await this.ctx.oauth.set(args.type, r._id, this.user._id); + return; } + + const uid = await this.ctx.oauth.get(args.type, r._id) || await this.ctx.oauth.get('mail', r.email); + if (uid) { + await successfulAuth.call(this, await user.getById('system', uid)); + this.response.redirect = '/'; + return; + } + this.checkPriv(PRIV.PRIV_REGISTER_USER); + let username = ''; + r.uname ||= []; + const mailDomain = r.email.split('@')[1]; + if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain); + for (const uname of r.uname) { + // eslint-disable-next-line no-await-in-loop + const nudoc = await user.getByUname('system', uname); + if (!nudoc) { + username = uname; + break; + } + } + const set: Partial = { ...r.set }; + if (r.bio) set.bio = r.bio; + if (r.viewLang) set.viewLang = r.viewLang; + if (r.avatar) set.avatar = r.avatar; + const [t] = await token.add( + token.TYPE_REGISTRATION, + system.get('session.unsaved_expire_seconds'), + { + mail: r.email, + username, + redirect: this.domain.registerRedirect, + set, + setInDomain: r.setInDomain, + identity: { + provider: args.type, + platform: args.type, + id: r._id, + }, + }, + ); + this.response.redirect = this.url('user_register_with_code', { code: t }); } } @@ -505,6 +510,8 @@ class ContestModeHandler extends Handler { } } +export const inject = ['oauth']; + export async function apply(ctx: Context) { ctx.Route('user_login', '/login', UserLoginHandler); ctx.Route('user_oauth', '/oauth/:type', OauthHandler); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 8f346cba7..a4d13289b 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -8,6 +8,7 @@ import type { } from '@hydrooj/common/types'; import type { Context } from './context'; import type { DocStatusType } from './model/document'; +import type { OauthMap } from './model/oauth'; import type { ProblemDoc } from './model/problem'; import type { Handler } from './service/server'; @@ -505,12 +506,7 @@ export interface OpCountDoc { opcount: number; } -export interface OauthMap { - /** source openId */ - _id: string; - /** target uid */ - uid: number; -} +export type { OauthMap } from './model/oauth'; export interface DiscussionHistoryDoc { title?: string; @@ -628,6 +624,7 @@ export interface UI { export interface ModuleInterfaces { oauth: { text: string; + name: string; icon?: string; get: (this: Handler) => Promise; callback: (this: Handler, args: Record) => Promise; diff --git a/packages/hydrooj/src/model/oauth.ts b/packages/hydrooj/src/model/oauth.ts index 34225aa8e..e37fb7424 100644 --- a/packages/hydrooj/src/model/oauth.ts +++ b/packages/hydrooj/src/model/oauth.ts @@ -1,23 +1,57 @@ -import db from '../service/db'; +import { Collection } from 'mongodb'; +import { Context, Service } from '../context'; -const coll = db.collection('oauth'); +export interface OauthMap { + platform: string; + /** source openId */ + id: string; + /** target uid */ + uid: number; +} -class OauthModel { - static async get(_id: string) { - const doc = await coll.findOne({ _id }); +declare module '../context' { + interface Context { + oauth: OauthModel; + } +} + +export default class OauthModel extends Service { + static inject = ['db']; + coll: Collection; + + constructor(ctx: Context) { + super(ctx, 'oauth'); + this.coll = ctx.db.collection('oauth'); + } + + async [Context.init]() { + await this.ctx.db.ensureIndexes(this.coll, + // { key: { platform: 1, id: 1 }, name: 'platform_id', unique: true }, + { key: { uid: 1, platform: 1 }, name: 'uid_platform' }, + ); + console.log('oauth init'); + } + + async get(platform: string, id: string) { + const doc = await this.coll.findOne({ platform, id }); if (doc) return doc.uid; return null; } - static async set(_id: string, uid: number) { - const res = await coll.findOneAndUpdate( - { _id }, + async set(platform: string, id: string, uid: number) { + const res = await this.coll.findOneAndUpdate( + { platform, id }, { $set: { uid } }, { upsert: true, returnDocument: 'after' }, ); return res?.uid; } -} -export default OauthModel; -global.Hydro.model.oauth = OauthModel; + async unbind(platform: string, id: string, uid: number) { + await this.coll.deleteOne({ platform, id, uid }); + } + + async list(uid: number) { + return this.coll.find({ uid }).toArray(); + } +} diff --git a/packages/hydrooj/src/service/migration.ts b/packages/hydrooj/src/service/migration.ts index e215171e7..38cd7a5ce 100644 --- a/packages/hydrooj/src/service/migration.ts +++ b/packages/hydrooj/src/service/migration.ts @@ -3,7 +3,7 @@ import { Logger } from '@hydrooj/utils'; import { Context, Service } from '../context'; import * as system from '../model/system'; -export type MigrationScript = null | (() => Promise); +export type MigrationScript = null | ((ctx: Context) => Promise); const logger = new Logger('migration'); declare module '../context' { @@ -39,7 +39,7 @@ export default class MigrationService extends Service { const isFresh = !dbVer; const expected = scripts.length; if (isFresh) { - await scripts[0]?.(); + await scripts[0]?.(this.ctx); await system.set(name, expected); continue; } @@ -53,11 +53,11 @@ export default class MigrationService extends Service { if ('dontWait' in func) { logger.info('[Background Task]'); // For those scripts we don't really care if they fail - func().then(() => { + func(this.ctx).then(() => { logger.info('Background Task Completed [%s]: from %d to %d', channel, func.dontWait, expected); }).catch(logger.error); } else { - const result = await func(); + const result = await func(this.ctx); if (!result) break; } dbVer++; diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 068d25c1c..424821f4e 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -264,6 +264,7 @@ export async function apply(ctx: Context) { id: key, icon: global.Hydro.module.oauth[key].icon, text: global.Hydro.module.oauth[key].text, + name: global.Hydro.module.oauth[key].name, })); if (!h.noCheckPermView && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW); if (h.context.pendingError) throw h.context.pendingError; diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 5466b5b22..0fb5df59c 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -587,4 +587,17 @@ export const coreScripts: MigrationScript[] = [ ); return true; }, + async function _92_93(ctx) { + await ctx.inject(['oauth'], async (c) => { + await iterateAllUser(async (udoc) => { + if (udoc.mailLower.endsWith('.local')) return; + await c.oauth.set('mail', udoc.mailLower, udoc._id); + }); + const old = await c.oauth.coll.find({ _id: { $type: 'string' } }).toArray(); + for (const r of old) { + if ((r._id as any).endsWith('@github.local')) await c.oauth.set('github', (r._id as any).split('@')[0], r.uid); + } + }); + return true; + }, ]; diff --git a/packages/login-with-github/index.ts b/packages/login-with-github/index.ts index 5d04c300e..a9d7d69d1 100644 --- a/packages/login-with-github/index.ts +++ b/packages/login-with-github/index.ts @@ -3,6 +3,10 @@ import { TokenModel, UserFacingError, ValidationError, } from 'hydrooj'; +// from https://github.com/logos +// eslint-disable-next-line max-len +const icon = ''; + export default class LoginWithGithubService extends Service { static Config = Schema.object({ id: Schema.string().description('Github OAuth AppID').required(), @@ -14,6 +18,8 @@ export default class LoginWithGithubService extends Service { super(ctx, 'oauth.github'); ctx.provideModule('oauth', 'github', { text: 'Login with Github', + name: 'Github', + icon, callback: async function callback({ state, code }) { const s = await TokenModel.get(state, TokenModel.TYPE_OAUTH); if (!s) throw new ValidationError('token'); @@ -38,7 +44,7 @@ export default class LoginWithGithubService extends Service { .set('Accept', 'application/vnd.github.v3+json') .set('Authorization', `token ${t}`); const ret = { - _id: `${userInfo.body.id}@github.local`, + _id: userInfo.body.id, email: userInfo.body.email, bio: userInfo.body.bio, uname: [userInfo.body.name, userInfo.body.login].filter((i) => i), diff --git a/packages/login-with-google/index.ts b/packages/login-with-google/index.ts index db7b6b7a7..528326cb7 100644 --- a/packages/login-with-google/index.ts +++ b/packages/login-with-google/index.ts @@ -36,6 +36,7 @@ export default class GoogleOAuthService extends Service { super(ctx, 'oauth.google'); ctx.provideModule('oauth', 'google', { text: 'Login with Google', + name: 'Google', callback: async function callback(this: Handler, { state, code, error, }) { diff --git a/packages/telegram/index.ts b/packages/telegram/index.ts new file mode 100644 index 000000000..1a60168f6 --- /dev/null +++ b/packages/telegram/index.ts @@ -0,0 +1,53 @@ +import { createHash, createHmac } from 'crypto'; +import { + Context, ForbiddenError, Handler, Schema, Service, +} from 'hydrooj'; + +// from https://www.svgrepo.com/svg/333610/telegram +// eslint-disable-next-line max-len +const icon = ''; + +export default class TelegramService extends Service { + static Config = Schema.object({ + token: Schema.string().role('secret').required(), + botLogin: Schema.string().required(), + endpoint: Schema.string().default('https://api.telegram.org/bot'), + }); + + constructor(ctx: Context, config: ReturnType) { + super(ctx, 'oauth.telegram'); + ctx.provideModule('oauth', 'telegram', { + text: 'Login with Telegram', + name: 'Telegram', + icon, + callback: async function callback({ payload }) { + const parsed = JSON.parse(payload); + const hash = parsed.hash; + delete parsed.hash; + const dataCheckString = Object.keys(parsed).sort().map((key) => (`${key}=${parsed[key]}`)).join('\n'); + const secretKey = createHash('sha256').update(config.token).digest(); + const dataCheckSum = createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); + if (hash !== dataCheckSum) throw new ForbiddenError('Invalid hash'); + const { + id, first_name, last_name, username, photo_url, + } = parsed; + const ret = { + _id: id, + email: `${id}@telegram.local`, + bio: '', + uname: [`${first_name} ${last_name}`, username, `telegram${id.toString()}`].filter((i) => i), + avatar: `url:${photo_url}`, + }; + if (!ret.email) throw new ForbiddenError("You don't have a verified email."); + return ret; + }, + async get(this: Handler) { + this.response.body = { botLogin: config.botLogin }; + this.response.template = 'telegram_login.html'; + }, + }); + ctx.i18n.load('zh', { + 'Login With Telegram': '使用 Telegram 登录', + }); + } +} diff --git a/packages/telegram/package.json b/packages/telegram/package.json new file mode 100644 index 000000000..8f4aeef6c --- /dev/null +++ b/packages/telegram/package.json @@ -0,0 +1,4 @@ +{ + "name": "@hydrooj/telegram", + "version": "0.0.1" +} diff --git a/packages/telegram/templates/telegram_login.html b/packages/telegram/templates/telegram_login.html new file mode 100644 index 000000000..394fd7a4d --- /dev/null +++ b/packages/telegram/templates/telegram_login.html @@ -0,0 +1,20 @@ +{% extends "layout/immersive.html" %} +{% block content %} +
+
+
+

{{ _('Login with Telegram') }}

+
+
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/pages/home_security.page.styl b/packages/ui-default/pages/home_security.page.styl index d10439a94..da946a8a8 100644 --- a/packages/ui-default/pages/home_security.page.styl +++ b/packages/ui-default/pages/home_security.page.styl @@ -20,7 +20,7 @@ line-height: rem($form-control-height) color: green - .autherlist + .autherlist, .platform-list font-size: rem($font-size-secondary) .autherlist__icon @@ -33,13 +33,34 @@ padding: rem(10px 0) &:not(:last-child) - border-bottom: 1px solid #DDD + border-bottom: 1px solid #DDD input margin: 0 + .platform-list__item + margin: rem(6px 0) + padding: rem(6px 0) + + &:not(:first-child) + border-top: 1px solid #DDD + + .button + margin: 0 + + .media__left + width: rem(48px) + text-align: center + + svg + width: rem(30px) + height: rem(30px) + color: #888 + fill: #888 + margin: rem(0 20px) + +mobile() - .sessionlist__item + .sessionlist__item, .platform-list__item .media__left display: none diff --git a/packages/ui-default/pages/home_security.page.ts b/packages/ui-default/pages/home_security.page.ts index 53a4b61a1..1d5ffbc61 100644 --- a/packages/ui-default/pages/home_security.page.ts +++ b/packages/ui-default/pages/home_security.page.ts @@ -11,6 +11,59 @@ import { const t = (s) => escape(i18n(s)); +async function changeMail() { + const changeMailDialog = new ActionDialog({ + $body: tpl` +
+

${i18n('Please enter your current password and new email address:')}

+ + + +
+ `, + onDispatch(action) { + if (action === 'ok') { + const $password = $('[name="password"]'); + const $mail = $('[name="mail"]'); + if (!$password.val() || !$mail.val()) { + if (!$password.val()) $password.focus(); + else $mail.focus(); + return false; + } + } + return true; + }, + }).open(); + const action = await changeMailDialog; + if (action !== 'ok') return; + try { + await request.post('', { + operation: 'change_mail', + password: $('[name="password"]').val(), + mail: $('[name="mail"]').val(), + }); + } catch (err) { + Notification.error(err.message); + console.error(err); + return; + } + Notification.success(i18n('Successfully changed.')); + await delay(2000); + window.location.reload(); +} + async function enableTfa() { const enableTFA = new ActionDialog({ $body: tpl` @@ -143,4 +196,9 @@ export default new NamedPage('home_security', () => { if (action === 'tfa') enableTfa(); else enableAuthn(action); }); + + $(document).on('click', '[data-operation="change_mail"]', async (ev) => { + ev.preventDefault(); + await changeMail(); + }); }); diff --git a/packages/ui-default/templates/home_security.html b/packages/ui-default/templates/home_security.html index ae47ea3d1..50d525c7d 100644 --- a/packages/ui-default/templates/home_security.html +++ b/packages/ui-default/templates/home_security.html @@ -11,43 +11,6 @@ {% endif %} -
-
-
-

{{ _('Change Email') }}

-
-
-
- {{ form.form_text({ - type:'password', - label:"SuperUser's Password" if sudoUid else 'Current Password', - columns:10, - name:'password', - required:true - }) }} - {{ form.form_text({ - type:'text', - label:'Current Email', - columns:10, - name:'currentEmail', - value:handler.user.mail, - disabled:true - }) }} - {{ form.form_text({ - type:'text', - label:'New Email', - columns:10, - name:'mail', - required:true - }) }} -
- - -
-
-
-
-
@@ -84,6 +47,70 @@
+
+
+
+

{{ _('Linked Accounts') }}

+
+
+
    +
  • +
    +
    + +
    +
    +

    {{ _('Email Account') }}: {{ handler.user.mail }}

    +
    +
    + +
    +
    +
  • +
    + + {%- for relation in relations -%}{% if relation.platform != 'mail' %} + {% set platform = handler.loginMethods.find(eval("r => r.id === '" + relation.platform + "'")) %} +
  • +
    + {% if platform.icon %} +
    {{ platform.icon|safe }}
    + {% endif %} +
    +

    {{ platform.name }}: {{ relation.id }}

    +
    +
    + +
    +
    +
  • + {%- endif -%}{%- endfor -%} +
    +
    + + {%- for method in handler.loginMethods -%} + {%- if not relations.some(eval("r => r.platform === '" + method.id + "'")) -%} +
  • +
    + {% if method.icon %} +
    {{ method.icon|safe }}
    + {% endif %} +
    +

    {{ method.name }}

    +
    +
    + +
    +
    +
  • + {%- endif -%} + {%- endfor -%} +
    +
+ +
+
+