diff --git a/components/Roles.vue b/components/Roles.vue
index 08c4d2d7b..4e05a836f 100644
--- a/components/Roles.vue
+++ b/components/Roles.vue
@@ -59,6 +59,7 @@ export default {
'code',
'org',
'impersonate',
+ 'community',
],
};
},
diff --git a/routes/profile.vue b/routes/profile.vue
index 06d702538..f3c013d63 100644
--- a/routes/profile.vue
+++ b/routes/profile.vue
@@ -142,7 +142,7 @@
-
+
@@ -150,17 +150,45 @@
Impersonate
Audit log
+
+
+ Authenticators
+
+
+
+
+ Include expired ones
+
+
+
+
+ -
+
+
+
+ {{ $datetime($ulidTime(authenticator.id)) }}
+
{{ authenticator.payload }}
+
+
+
+
+
@@ -276,6 +304,9 @@ export default mainPronoun.extend({
hasSus: false,
contentWarningDismissed: false,
+
+ authenticators: undefined,
+ showExpiredAuthenticators: false,
};
},
head() {
@@ -342,6 +373,9 @@ export default mainPronoun.extend({
}
this.terms = await this.$axios.$get('/terms');
}
+ if (this.$isGranted('*') || this.$isGranted('community')) {
+ this.authenticators = await this.$axios.$get(`/admin/authenticators/${this.user.id}`);
+ }
},
methods: {
async generateCard(dark) {
@@ -397,6 +431,20 @@ export default mainPronoun.extend({
await this.$router.push(`/${this.$config.user.route}`);
setTimeout(() => window.location.reload(), 500);
},
+ authenticatorIcon(type) {
+ switch (type) {
+ case 'email':
+ case 'changedEmail':
+ return 'envelope';
+ case 'indieauth':
+ return 'indieauth.png';
+ case 'mfa_secret':
+ case 'mfa_recovery':
+ return 'mobile';
+ default:
+ return `b:${type}`;
+ }
+ },
},
});
diff --git a/server/routes/admin.js b/server/routes/admin.js
index 3066cc23a..a3b46afb2 100644
--- a/server/routes/admin.js
+++ b/server/routes/admin.js
@@ -1,7 +1,7 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
import avatar from '../avatar.ts';
-import { buildDict, now, shuffle, handleErrorAsync } from '../../src/helpers.ts';
+import { buildDict, now, shuffle, handleErrorAsync, filterObjectKeys } from '../../src/helpers.ts';
import allLocales from '../../locale/locales.ts';
import fs from 'fs';
import { caches } from '../../src/cache.js';
@@ -657,4 +657,27 @@ router.get('/admin/audit-log/:username/:id', handleErrorAsync(async (req, res) =
`));
}));
+router.get('/admin/authenticators/:id', handleErrorAsync(async (req, res) => {
+ if (!req.isGranted('community') && !req.isGranted('*')) {
+ return res.status(401).json({ error: 'Unauthorised' });
+ }
+
+ const authenticators = (await req.db.all(SQL`
+ SELECT * FROM authenticators
+ WHERE userId = ${req.params.id}
+ ORDER BY id DESC
+ `)).map((auth) => {
+ delete auth.userId;
+
+ const payload = JSON.parse(auth.payload);
+ auth.payload = typeof payload === 'string'
+ ? null
+ : filterObjectKeys(payload, ['id', 'email', 'name', 'instance', 'username']);
+
+ return auth;
+ });
+
+ return res.json(authenticators);
+}));
+
export default router;
diff --git a/src/helpers.ts b/src/helpers.ts
index 4b938ac09..5d3be744d 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -258,7 +258,7 @@ export const randomItemWeighted = (array: T[]): T => {
export const randomNumber = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min;
-const RESTRICTED_AREAS = ['code', 'org', 'impersonate'];
+const RESTRICTED_AREAS = ['code', 'org', 'impersonate', 'community'];
export const isGranted = (user: Pick, locale: string | null, area: string = ''): boolean => {
if (area === '*') {
@@ -502,3 +502,12 @@ export const parseUserJwt = (token: string): string | JwtPayload | null => {
return null;
}
};
+
+export const filterObjectKeys = , K extends keyof T>(obj: T, keysToKeep: K[]): Pick => {
+ return keysToKeep.reduce((filteredObj, key) => {
+ if (key in obj) {
+ filteredObj[key] = obj[key];
+ }
+ return filteredObj;
+ }, {} as Pick);
+};