core: support oauth bind/unbind account (#971)

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and GitHub committed 2025-04-01 10:55:21 +01:00
1 parent 1a8232899c
commit 536c697f4b
16 files changed
+393 -141

No files matched your search

+3 -2
View File
@@ -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, '');
}
}
+10 -1
View File
@@ -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);
+83 -76
View File
@@ -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<Udoc> = { ...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<Udoc> = { ...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);
+3 -6
View File
@@ -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<void>;
callback: (this: Handler, args: Record<string, any>) => Promise<OAuthUserResponse>;
+45 -11
View File
@@ -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<OauthMap>;
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();
}
}
+4 -4
View File
@@ -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<boolean | void>);
export type MigrationScript = null | ((ctx: Context) => Promise<boolean | void>);
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++;
+1
View File
@@ -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;
+13
View File
@@ -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;
},
];
+7 -1
View File
@@ -3,6 +3,10 @@ import {
TokenModel, UserFacingError, ValidationError,
} from 'hydrooj';
// from https://github.com/logos
// eslint-disable-next-line max-len
const icon = '<svg viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>';
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),
+1
View File
@@ -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,
}) {
+53
View File
@@ -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 = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m20.665 3.717-17.73 6.837c-1.21.486-1.203 1.161-.222 1.462l4.552 1.42 10.532-6.645c.498-.303.953-.14.579.192l-8.533 7.701h-.002l.002.001-.314 4.692c.46 0 .663-.211.921-.46l2.211-2.15 4.599 3.397c.848.467 1.457.227 1.668-.785l3.019-14.228c.309-1.239-.473-1.8-1.282-1.434z"/></svg>';
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<typeof TelegramService.Config>) {
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 登录',
});
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "@hydrooj/telegram",
"version": "0.0.1"
}
@@ -0,0 +1,20 @@
{% extends "layout/immersive.html" %}
{% block content %}
<div class="row"><div class="columns">
<div class="immersive--content immersive--center">
<div class="row"><div class="columns">
<h1>{{ _('Login with Telegram') }}</h1>
</div></div>
</div>
</div></div>
<div class="row"><div class="columns" id="telegram-login" style="text-align: center;">
<script async src="https://telegram.org/js/telegram-widget.js?22"
data-telegram-login="{{ botLogin }}" data-size="large" data-onauth="onTelegramAuth(user)"
data-request-access="write"></script>
<script type="text/javascript">
function onTelegramAuth(user) {
window.location.href = '/oauth/telegram/callback?payload=' + encodeURIComponent(JSON.stringify(user));
}
</script>
</div></div>
{% endblock %}
@@ -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
@@ -11,6 +11,59 @@ import {
const t = (s) => escape(i18n(s));
async function changeMail() {
const changeMailDialog = new ActionDialog({
$body: tpl`
<div class="typo">
<p>${i18n('Please enter your current password and new email address:')}</p>
<label>${i18n('Current Password')}
<div class="textbox-container">
<input class="textbox" type="password" name="password" data-autofocus required></input>
</div>
</label>
<label>${i18n('Current Email')}
<div class="textbox-container">
<input class="textbox" type="text" name="currentEmail" value="${UserContext.mail}" disabled></input>
</div>
</label>
<label>${i18n('New Email')}
<div class="textbox-container">
<input class="textbox" type="text" name="mail" required></input>
</div>
</label>
</div>
`,
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();
});
});
@@ -11,43 +11,6 @@
</div>
</div>
{% endif %}
<div class="medium-6 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title" id="email" data-heading>{{ _('Change Email') }}</h1>
</div>
<div class="section__body">
<form method="post">
{{ 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
}) }}
<div class="row"><div class="columns">
<input type="hidden" name="operation" value="change_mail">
<input type="submit" class="rounded primary button" value="{{ _('Change Email') }}">
</div></div>
</form>
</div>
</div>
</div>
<div class="medium-6 columns">
<div class="section">
<div class="section__header">
@@ -84,6 +47,70 @@
</div>
</div>
</div>
<div class="medium-6 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title" id="linked-accounts" data-heading>{{ _('Linked Accounts') }}</h1>
</div>
<div class="section__body">
<ul class="platform-list">
<li class="platform-list__item">
<div class="media">
<div class="media__left medium">
<span class="autherlist__icon icon icon-mail"></span>
</div>
<div class="media__body medium typo">
<p>{{ _('Email Account') }}: {{ handler.user.mail }}</p>
</div>
<div class="media__right medium">
<button type="button" class="rounded button" data-operation="change_mail">{{ _('Change') }}</button>
</div>
</div>
</li>
<form method="post">
<input type="hidden" name="operation" value="unlink_account">
{%- for relation in relations -%}{% if relation.platform != 'mail' %}
{% set platform = handler.loginMethods.find(eval("r => r.id === '" + relation.platform + "'")) %}
<li class="platform-list__item">
<div class="media">
{% if platform.icon %}
<div class="media__left medium">{{ platform.icon|safe }}</div>
{% endif %}
<div class="media__body medium typo">
<p>{{ platform.name }}: {{ relation.id }}</p>
</div>
<div class="media__right medium">
<button type="submit" name="platform" value="{{ relation.platform }}" class="rounded button">{{ _('Unlink') }}</button>
</div>
</div>
</li>
{%- endif -%}{%- endfor -%}
</form>
<form method="post">
<input type="hidden" name="operation" value="link_account">
{%- for method in handler.loginMethods -%}
{%- if not relations.some(eval("r => r.platform === '" + method.id + "'")) -%}
<li class="platform-list__item">
<div class="media">
{% if method.icon %}
<div class="media__left medium">{{ method.icon|safe }}</div>
{% endif %}
<div class="media__body">
<p>{{ method.name }}</p>
</div>
<div class="media__right">
<button type="submit" name="platform" value="{{ method.id }}" class="rounded button">{{ _('Link') }}</button>
</div>
</div>
</li>
{%- endif -%}
{%- endfor -%}
</form>
</ul>
</ul>
</div>
</div>
</div>
</div>
<div class="row nojs--hide">
<div class="columns">