瀏覽代碼

Merge pull request #266 from HeyPuter/eric/session-updates

session management
Eric Dubé 1 年之前
父節點
當前提交
8135e076c2

+ 4 - 0
packages/backend/src/config.js

@@ -97,6 +97,10 @@ if (config.server_id) {
 
 config.contact_email = 'hey@' + config.domain;
 
+// TODO: default value will be changed to false in a future release;
+//       details to follow in a future announcement.
+config.legacy_token_migrate = true;
+
 module.exports = config;
 
 // NEW_CONFIG_LOADING

+ 11 - 20
packages/backend/src/middleware/auth.js

@@ -17,35 +17,26 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 "use strict"
+const APIError = require('../api/APIError');
 const {jwt_auth} = require('../helpers');
+const { UserActorType } = require('../services/auth/Actor');
 const { DB_WRITE } = require('../services/database/consts');
 const { Context } = require('../util/context');
+const auth2 = require('./auth2');
 
 const auth = async (req, res, next)=>{
+    let auth2_ok = false;
     try{
-        let auth_res = await jwt_auth(req);
+        // Delegate to new middleware
+        await auth2(req, res, () => { auth2_ok = true; });
+        if ( ! auth2_ok ) return;
 
-        // is account suspended?
-        if(auth_res.user.suspended)
-            return res.status(401).send({error: 'Account suspended'});
-
-        // successful auth
-        req.user = auth_res.user;
-        req.token = auth_res.token;
-
-        // let's add it to the context too
-        try {
-        const x = Context.get();
-        x.set('user', req.user);
-        } catch (e) {
-        console.error(e);
+        // Everything using the old reference to the auth middleware
+        // should only allow session tokens
+        if ( ! (req.actor.type instanceof UserActorType) ) {
+            throw APIError.create('forbidden');
         }
 
-        // record as daily active users
-        const db = req.services.get('database').get(DB_WRITE, 'auth');
-        db.write('UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', [req.user.id]);
-
-        // go to next
         next();
     }
     // auth failed

+ 36 - 0
packages/backend/src/middleware/auth2.js

@@ -18,8 +18,24 @@
  */
 const APIError = require("../api/APIError");
 const config = require("../config");
+const { UserActorType } = require("../services/auth/Actor");
+const { LegacyTokenError } = require("../services/auth/AuthService");
 const { Context } = require("../util/context");
 
+// The "/whoami" endpoint is a special case where we want to allow
+// a legacy token to be used for authentication. The "/whoami"
+// endpoint will then return a new token for further requests.
+//
+const is_whoami = (req) => {
+    if ( ! config.legacy_token_migrate ) return;
+
+    if ( req.path !== '/whoami' ) return;
+
+    // const subdomain = req.subdomains[res.subdomains.length - 1];
+    // if ( subdomain !== 'api' ) return;
+    return true;
+}
+
 // TODO: Allow auth middleware to be used without requiring
 // authentication. This will allow us to use the auth middleware
 // in endpoints that do not require authentication, but can
@@ -70,6 +86,26 @@ const auth2 = async (req, res, next) => {
             e.write(res);
             return;
         }
+        if ( e instanceof LegacyTokenError && is_whoami(req) ) {
+            const new_info = await svc_auth.check_session(token, {
+                req,
+                from_upgrade: true,
+            })
+            context.set('actor', new_info.actor);
+            context.set('user', new_info.user);
+            req.new_token = new_info.token;
+            req.token = new_info.token;
+            req.user = new_info.user;
+            req.actor = new_info.actor;
+
+            res.cookie(config.cookie_name, new_info.token, {
+                sameSite: 'none',
+                secure: true,
+                httpOnly: true,
+            });
+            next();
+            return;
+        }
         const re = APIError.create('token_auth_failed');
         re.write(res);
         return;

+ 23 - 0
packages/backend/src/routers/auth/list-sessions.js

@@ -0,0 +1,23 @@
+const eggspress = require("../../api/eggspress");
+const { UserActorType } = require("../../services/auth/Actor");
+const { Context } = require("../../util/context");
+
+module.exports = eggspress('/auth/list-sessions', {
+    subdomain: 'api',
+    auth2: true,
+    allowedMethods: ['GET'],
+}, async (req, res, next) => {
+    const x = Context.get();
+    const svc_auth = x.get('services').get('auth');
+
+    // Only users can list their own sessions
+    // apps, access tokens, etc should NEVER access this
+    const actor = x.get('actor');
+    if ( ! (actor.type instanceof UserActorType) ) {
+        throw APIError.create('forbidden');
+    }
+
+    const sessions = await svc_auth.list_sessions(actor);
+
+    res.json(sessions);
+});

+ 33 - 0
packages/backend/src/routers/auth/revoke-session.js

@@ -0,0 +1,33 @@
+const APIError = require("../../api/APIError");
+const eggspress = require("../../api/eggspress");
+const { UserActorType } = require("../../services/auth/Actor");
+const { Context } = require("../../util/context");
+
+module.exports = eggspress('/auth/revoke-session', {
+    subdomain: 'api',
+    auth2: true,
+    allowedMethods: ['POST'],
+}, async (req, res, next) => {
+    const x = Context.get();
+    const svc_auth = x.get('services').get('auth');
+
+    // Only users can list their own sessions
+    // apps, access tokens, etc should NEVER access this
+    const actor = x.get('actor');
+    if ( ! (actor.type instanceof UserActorType) ) {
+        throw APIError.create('forbidden');
+    }
+
+    // Ensure valid UUID
+    if ( ! req.body.uuid || typeof req.body.uuid !== 'string' ) {
+        throw APIError.create('field_invalid', null, {
+            key: 'uuid',
+            expected: 'string'
+        });
+    }
+
+    const sessions = await svc_auth.revoke_session(
+        actor, req.body.uuid);
+
+    res.json({ sessions });
+});

+ 2 - 1
packages/backend/src/routers/login.js

@@ -89,7 +89,8 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res
             return res.status(400).send('Incorrect password.')
         // check password
         if(await bcrypt.compare(req.body.password, user.password)){
-            const token = await jwt.sign({uuid: user.uuid}, config.jwt_secret)
+            const svc_auth = req.services.get('auth');
+            const token = await svc_auth.create_session_token(user, { req });
             //set cookie
             // res.cookie(config.cookie_name, token);
             res.cookie(config.cookie_name, token, {

+ 13 - 5
packages/backend/src/routers/signup.js

@@ -52,6 +52,7 @@ module.exports = eggspress(['/signup'], {
     const validator = require('validator')
     let uuid_user;
 
+    const svc_auth = Context.get('services').get('auth');
     const svc_authAudit = Context.get('services').get('auth-audit');
     svc_authAudit.record({
         requester: Context.get('requester'),
@@ -67,9 +68,11 @@ module.exports = eggspress(['/signup'], {
 
     // check if user is already logged in
     if ( req.body.is_temp && req.cookies[config.cookie_name] ) {
-        const token = req.cookies[config.cookie_name];
-        const decoded = await jwt.verify(token, config.jwt_secret);
-        const user = await get_user({ uuid: decoded.uuid });
+        const { user, token } = await svc_auth.check_session(
+            req.cookies[config.cookie_name]
+        );
+        // const decoded = await jwt.verify(token, config.jwt_secret);
+        // const user = await get_user({ uuid: decoded.uuid });
         if ( user ) {
             return res.send({
                 token: token,
@@ -233,17 +236,22 @@ module.exports = eggspress(['/signup'], {
         db.write('UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', [pseudo_user.id]);
         invalidate_cached_user_by_id(pseudo_user.id);
     }
-    // create token for login
-    const token = await jwt.sign({uuid: user_uuid}, config.jwt_secret);
 
     // user id
     // todo if pseudo user, assign directly no need to do another DB lookup
     const user_id = (pseudo_user === undefined) ? insert_res.insertId : pseudo_user.id;
+
     const [user] = await db.read(
         'SELECT * FROM `user` WHERE `id` = ? LIMIT 1',
         [user_id]
     );
 
+    // create token for login
+    const token = await svc_auth.create_session_token(user, {
+        req,
+    });
+        // jwt.sign({uuid: user_uuid}, config.jwt_secret);
+
     //-------------------------------------------------------------
     // email confirmation
     //-------------------------------------------------------------

+ 15 - 2
packages/backend/src/routers/whoami.js

@@ -54,6 +54,7 @@ const WHOAMI_GET = eggspress('/whoami', {
         is_temp: (req.user.password === null && req.user.email === null),
         taskbar_items: await get_taskbar_items(req.user),
         referral_code: req.user.referral_code,
+        ...(req.new_token ? { token: req.token } : {})
     };
 
     if ( ! is_user ) {
@@ -65,6 +66,7 @@ const WHOAMI_GET = eggspress('/whoami', {
         delete details.desktop_bg_color;
         delete details.desktop_bg_fit;
         delete details.taskbar_items;
+        delete details.token;
     }
 
     res.send(details);
@@ -76,8 +78,19 @@ const WHOAMI_GET = eggspress('/whoami', {
 const WHOAMI_POST = new express.Router();
 WHOAMI_POST.post('/whoami', auth, fs, express.json(), async (req, response, next)=>{
     // check subdomain
-    if(require('../helpers').subdomain(req) !== 'api')
-        next();
+    if(require('../helpers').subdomain(req) !== 'api') {
+        return;
+    }
+
+    const actor = Context.get('actor');
+    if ( ! actor ) {
+        throw Error('actor not found in context');
+    }
+
+    const is_user = actor.type instanceof UserActorType;
+    if ( ! is_user ) {
+        throw Error('actor is not a user');
+    }
 
     let desktop_items = [];
 

+ 2 - 0
packages/backend/src/services/PuterAPIService.js

@@ -32,6 +32,8 @@ class PuterAPIService extends BaseService {
         app.use(require('../routers/auth/grant-user-user'));
         app.use(require('../routers/auth/revoke-user-user'));
         app.use(require('../routers/auth/list-permissions'))
+        app.use(require('../routers/auth/list-sessions'))
+        app.use(require('../routers/auth/revoke-session'))
         app.use(require('../routers/auth/check-app'))
         app.use(require('../routers/auth/app-uid-from-origin'))
         app.use(require('../routers/auth/create-access-token'))

+ 187 - 0
packages/backend/src/services/auth/AuthService.js

@@ -25,6 +25,8 @@ const { DB_WRITE } = require("../database/consts");
 
 const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8';
 
+const LegacyTokenError = class extends Error {};
+
 class AuthService extends BaseService {
     static MODULES = {
         jwt: require('jsonwebtoken'),
@@ -34,6 +36,8 @@ class AuthService extends BaseService {
 
     async _init () {
         this.db = await this.services.get('database').get(DB_WRITE, 'auth');
+
+        this.sessions = {};
     }
 
     async authenticate_from_token (token) {
@@ -43,6 +47,7 @@ class AuthService extends BaseService {
         );
 
         if ( ! decoded.hasOwnProperty('type') ) {
+            throw new LegacyTokenError();
             const user = await this.db.requireRead(
                 "SELECT * FROM `user` WHERE `uuid` = ?  LIMIT 1",
                 [decoded.uuid],
@@ -66,6 +71,26 @@ class AuthService extends BaseService {
             });
         }
 
+        if ( decoded.type === 'session' ) {
+            const session = await this.get_session_(decoded.uuid);
+
+            if ( ! session ) {
+                throw APIError.create('token_auth_failed');
+            }
+
+            const user = await get_user({ uuid: decoded.user_uid });
+
+            const actor_type = new UserActorType({
+                user,
+                session: session.uuid,
+            });
+
+            return new Actor({
+                user_uid: decoded.user_uid,
+                type: actor_type,
+            });
+        }
+
         if ( decoded.type === 'app-under-user' ) {
             const user = await get_user({ uuid: decoded.user_uid });
             if ( ! user ) {
@@ -149,6 +174,141 @@ class AuthService extends BaseService {
         return token;
     }
 
+    async create_session_ (user, meta = {}) {
+        this.log.info(`CREATING SESSION`);
+
+        if ( meta.req ) {
+            const req = meta.req;
+            delete meta.req;
+
+            const ip = this.global_config.fowarded
+                ? req.headers['x-forwarded-for'] ||
+                    req.connection.remoteAddress
+                : req.connection.remoteAddress
+                ;
+        
+            meta.ip = ip;
+
+            meta.server = this.global_config.server_id;
+
+            if ( req.headers['user-agent'] ) {
+                meta.user_agent = req.headers['user-agent'];
+            }
+
+            if ( req.headers['referer'] ) {
+                meta.referer = req.headers['referer'];
+            }
+
+            if ( req.headers['origin'] ) {
+                const origin = this._origin_from_url(req.headers['origin']);
+                if ( origin ) {
+                    meta.origin = origin;
+                }
+            }
+
+            if ( req.headers['host'] ) {
+                const host = this._origin_from_url(req.headers['host']);
+                if ( host ) {
+                    meta.host = host;
+                }
+            }
+        }
+
+        meta.created = new Date().toISOString();
+        meta.created_unix = Math.floor(Date.now() / 1000);
+
+        const uuid = this.modules.uuidv4();
+        await this.db.write(
+            'INSERT INTO `sessions` ' +
+            '(`uuid`, `user_id`, `meta`) ' +
+            'VALUES (?, ?, ?)',
+            [uuid, user.id, JSON.stringify(meta)],
+        );
+        const session = { uuid, user_uid: user.uuid, meta };
+        this.sessions[uuid] = session;
+        return session;
+    }
+
+    async get_session_ (uuid) {
+        this.log.info(`USING SESSION`);
+        if ( this.sessions[uuid] ) {
+            return this.sessions[uuid];
+        }
+
+        const [session] = await this.db.read(
+            "SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1",
+            [uuid],
+        );
+
+        session.meta = JSON.parse(session.meta ?? {});
+
+        return session;
+    }
+
+    async create_session_token (user, meta) {
+        const session = await this.create_session_(user, meta);
+
+        const token = this.modules.jwt.sign({
+            type: 'session',
+            version: '0.0.0',
+            uuid: session.uuid,
+            meta: session.meta,
+            user_uid: user.uuid,
+        }, this.global_config.jwt_secret);
+
+        return { session, token };
+    }
+
+    async check_session (cur_token, meta) {
+        const decoded = this.modules.jwt.verify(
+            cur_token, this.global_config.jwt_secret
+        );
+
+        console.log('\x1B[36;1mDECODED SESSION', decoded);
+
+        if ( decoded.type && decoded.type !== 'session' ) {
+            return {};
+        }
+
+        const is_legacy = ! decoded.type;
+        
+        const user = await get_user({ uuid:
+            is_legacy ? decoded.uuid : decoded.user_uid
+        });
+        if ( ! user ) {
+            return {};
+        }
+
+        if ( ! is_legacy ) {
+            // Ensure session exists
+            const session = await this.get_session_(decoded.uuid);
+            if ( ! session ) {
+                return {};
+            }
+
+            // Return the session
+            return { user, token: cur_token };
+        }
+
+        this.log.info(`UPGRADING SESSION`);
+
+        // Upgrade legacy token
+        // TODO: phase this out
+        const { session, token } = await this.create_session_token(user, meta);
+
+        const actor_type = new UserActorType({
+            user,
+            session,
+        });
+
+        const actor = new Actor({
+            user_uid: user.uuid,
+            type: actor_type,
+        });
+
+        return { actor, user, token };
+    }
+
     async create_access_token (authorizer, permissions) {
         const jwt_obj = {};
         const authorizer_obj = {};
@@ -206,6 +366,32 @@ class AuthService extends BaseService {
         return jwt;
     }
 
+    async list_sessions (actor) {
+        // We won't take the cached sessions here because it's
+        // possible the user has sessions on other servers
+        const sessions = await this.db.read(
+            'SELECT uuid, meta FROM `sessions` WHERE `user_id` = ?',
+            [actor.type.user.id],
+        );
+
+        sessions.forEach(session => {
+            if ( session.uuid === actor.type.session ) {
+                session.current = true;
+            }
+            session.meta = JSON.parse(session.meta ?? {});
+        });
+
+        return sessions;
+    }
+
+    async revoke_session (actor, uuid) {
+        delete this.sessions[uuid];
+        await this.db.write(
+            `DELETE FROM sessions WHERE uuid = ? AND user_id = ?`,
+            [uuid, actor.type.user.id]
+        );
+    }
+
     async get_user_app_token_from_origin (origin) {
         origin = this._origin_from_url(origin);
         const app_uid = await this._app_uid_from_origin(origin);
@@ -264,4 +450,5 @@ class AuthService extends BaseService {
 
 module.exports = {
     AuthService,
+    LegacyTokenError,
 };

+ 6 - 1
packages/backend/src/services/database/SqliteDatabaseAccessService.js

@@ -42,7 +42,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
         this.db = new Database(this.config.path);
 
         // Database upgrade logic
-        const TARGET_VERSION = 1;
+        const TARGET_VERSION = 2;
 
         if ( do_setup ) {
             this.log.noticeme(`SETUP: creating database at ${this.config.path}`);
@@ -50,6 +50,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
                 '0001_create-tables.sql',
                 '0002_add-default-apps.sql',
                 '0003_user-permissions.sql',
+                '0004_sessions.sql',
             ].map(p => path_.join(__dirname, 'sqlite_setup', p));
             const fs = require('fs');
             for ( const filename of sql_files ) {
@@ -70,6 +71,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
             upgrade_files.push('0003_user-permissions.sql');
         }
 
+        if ( user_version <= 1 ) {
+            upgrade_files.push('0004_sessions.sql');
+        }
+
         if ( upgrade_files.length > 0 ) {
             this.log.noticeme(`Database out of date: ${this.config.path}`);
             this.log.noticeme(`UPGRADING DATABASE: ${user_version} -> ${TARGET_VERSION}`);

+ 7 - 0
packages/backend/src/services/database/sqlite_setup/0004_sessions.sql

@@ -0,0 +1,7 @@
+CREATE TABLE `sessions` (
+    "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+    "user_id" INTEGER NOT NULL,
+    "uuid" TEXT NOT NULL,
+    "meta" JSON DEFAULT NULL,
+    FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);

+ 13 - 0
src/UI/Settings/UIWindowSettings.js

@@ -26,6 +26,7 @@ import changeLanguage from "../../i18n/i18nChangeLanguage.js"
 import UIWindowConfirmUserDeletion from './UIWindowConfirmUserDeletion.js';
 import UITabAbout from './UITabAbout.js';
 import UIWindowThemeDialog from '../UIWindowThemeDialog.js';
+import UIWindowManageSessions from '../UIWindowManageSessions.js';
 
 async function UIWindowSettings(options){
     return new Promise(async (resolve) => {
@@ -113,6 +114,14 @@ async function UIWindowSettings(options){
                         h += `</div>`;
                     h += `</div>`;
 
+                    // session manager
+                    h += `<div class="settings-card">`;
+                        h += `<strong>${i18n('sessions')}</strong>`;
+                        h += `<div style="flex-grow:1;">`;
+                            h += `<button class="button manage-sessions" style="float:right;">${i18n('manage_sessions')}</button>`;
+                        h += `</div>`;
+                    h += `</div>`;
+
                 h += `</div>`;
 
                 // Personalization
@@ -342,6 +351,10 @@ async function UIWindowSettings(options){
             UIWindowThemeDialog();
         })
 
+        $(el_window).find('.manage-sessions').on('click', function (e) {
+            UIWindowManageSessions();
+        })
+
         $(el_window).on('click', '.settings-sidebar-item', function(){
             const $this = $(this);
             const settings = $this.attr('data-settings');

+ 148 - 0
src/UI/UIWindowManageSessions.js

@@ -0,0 +1,148 @@
+import UIAlert from "./UIAlert.js";
+import UIWindow from "./UIWindow.js";
+
+const UIWindowManageSessions = async function UIWindowManageSessions () {
+    const services = globalThis.services;
+
+    const w = await UIWindow({
+        title: i18n('ui_manage_sessions'),
+        icon: null,
+        uid: null,
+        is_dir: false,
+        message: 'message',
+        // body_icon: options.body_icon,
+        // backdrop: options.backdrop ?? false,
+        is_droppable: false,
+        has_head: true,
+        selectable_body: false,
+        draggable_body: true,
+        allow_context_menu: false,
+        window_class: 'window-session-manager',
+        dominant: true,
+        body_content: '',
+        // width: 600,
+        // parent_uuid: options.parent_uuid,
+        // ...options.window_options,
+    });
+
+    const SessionWidget = ({ session }) => {
+        const el = document.createElement('div');
+        el.classList.add('session-widget');
+        el.dataset.uuid = session.uuid;
+        // '<pre>' +
+        //    JSON.stringify(session, null, 2) +
+        //     '</pre>';
+
+        const el_uuid = document.createElement('div');
+        el_uuid.textContent = session.uuid;
+        el.appendChild(el_uuid);
+        el_uuid.classList.add('session-widget-uuid');
+
+        const el_meta = document.createElement('div');
+        el_meta.classList.add('session-widget-meta');
+        for ( const key in session.meta ) {
+            const el_entry = document.createElement('div');
+            el_entry.classList.add('session-widget-meta-entry');
+
+            const el_key = document.createElement('div');
+            el_key.textContent = key;
+            el_key.classList.add('session-widget-meta-key');
+            el_entry.appendChild(el_key);
+
+            const el_value = document.createElement('div');
+            el_value.textContent = session.meta[key];
+            el_value.classList.add('session-widget-meta-value');
+            el_entry.appendChild(el_value);
+
+            el_meta.appendChild(el_entry);
+        }
+        el.appendChild(el_meta);
+
+        const el_actions = document.createElement('div');
+        el_actions.classList.add('session-widget-actions');
+
+        const el_btn_revoke = document.createElement('button');
+        el_btn_revoke.textContent = i18n('ui_revoke');
+        el_btn_revoke.classList.add('button', 'button-danger');
+        el_btn_revoke.addEventListener('click', async () => {
+            try{
+            const alert_resp = await UIAlert({
+                message: i18n('confirm_session_revoke'),
+                buttons:[
+                    {
+                        label: i18n('yes'),
+                        value: 'yes',
+                        type: 'primary',
+                    },
+                    {
+                        label: i18n('cancel')
+                    },
+                ]
+            });
+
+            if ( alert_resp !== 'yes' ) {
+                return;
+            }
+
+            const resp = await fetch(`${api_origin}/auth/revoke-session`, {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify({
+                    uuid: session.uuid,
+                }),
+            });
+            if ( resp.ok ) {
+                el.remove();
+                return;
+            }
+            UIAlert({ message: await resp.text() }).appendTo(w_body);
+            } catch ( e ) {
+                UIAlert({ message: e.toString() }).appendTo(w_body);
+            }
+        });
+        el_actions.appendChild(el_btn_revoke);
+        el.appendChild(el_actions);
+
+        return {
+            appendTo (parent) {
+                parent.appendChild(el);
+                return this;
+            }
+        };
+    };
+
+    const reload_sessions = async () => {
+        const resp = await fetch(`${api_origin}/auth/list-sessions`, {
+            method: 'GET',
+        });
+
+        const sessions = await resp.json();
+
+        for ( const el of w_body.querySelectorAll('.session-widget') ) {
+            if ( !sessions.find(s => s.uuid === el.dataset.uuid) ) {
+                el.remove();
+            }
+        }
+
+        for ( const session of sessions ) {
+            if ( w.querySelector(`.session-widget[data-uuid="${session.uuid}"]`) ) {
+                continue;
+            }
+            SessionWidget({ session }).appendTo(w_body);
+        }
+    };
+
+    const w_body = w.querySelector('.window-body');
+
+    w_body.classList.add('session-manager-list');
+
+    reload_sessions();
+    const interval = setInterval(reload_sessions, 8000);
+    w.on_close = () => {
+        clearInterval(interval);
+    }
+};
+
+export default UIWindowManageSessions;

+ 58 - 0
src/css/style.css

@@ -3712,3 +3712,61 @@ label {
   background: #04AA6D;
   cursor: pointer;
 }
+
+.session-manager-list {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    padding: 10px;
+    box-sizing: border-box;
+    height: 100% !important;
+}
+
+.session-widget {
+    display: flex;
+    flex-direction: column;
+    padding: 10px;
+    border: 1px solid #e0e0e0;
+    border-radius: 4px;
+    gap: 4px;
+}
+
+.session-widget-uuid {
+    font-size: 12px;
+    font-weight: 600;
+    color: #9c185b;
+}
+
+.session-widget-meta {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    max-height: 100px;
+    overflow-y: scroll;
+}
+
+.session-widget-meta-entry {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+}
+
+.session-widget-meta-key {
+    font-size: 12px;
+    color: #666;
+    flex-basis: 40%;
+    flex-shrink: 0;
+}
+
+.session-widget-meta-value {
+    font-size: 12px;
+    color: #666;
+    flex-grow: 1;
+}
+
+.session-widget-actions {
+    display: flex;
+    flex-direction: row;
+    gap: 10px;
+    justify-content: flex-end;
+}

+ 5 - 0
src/i18n/translations/en.js

@@ -51,6 +51,7 @@ const en = {
         confirm_new_password: "Confirm New Password",
         confirm_delete_user: "Are you sure you want to delete your account? All your files and data will be permanently deleted. This action cannot be undone.",
         confirm_delete_user_title: "Delete Account?",
+        confirm_session_revoke: "Are you sure you want to revoke this session?",
         contact_us: "Contact Us",
         contain: 'Contain',
         continue: "Continue",
@@ -112,6 +113,7 @@ const en = {
         log_in: "Log In",
         log_into_another_account_anyway: 'Log into another account anyway',
         log_out: 'Log Out',
+        manage_sessions: "Manage Sessions",
         move: 'Move',
         moving: "Moving",
         my_websites: "My Websites",
@@ -179,6 +181,7 @@ const en = {
         select: "Select",
         selected: 'selected',
         select_color: 'Select color…',
+        sessions: "Sessions",
         send: "Send",
         send_password_recovery_email: "Send Password Recovery Email",
         session_saved: "Thank you for creating an account. This session has been saved.",
@@ -206,6 +209,8 @@ const en = {
         type: 'Type',
         type_confirm_to_delete_account: "Type 'confirm' to delete your account.",
         ui_colors: "UI Colors",
+        ui_manage_sessions: "Session Manager",
+        ui_revoke: "Revoke",
         undo: 'Undo',
         unlimited: 'Unlimited',
         unzip: "Unzip",

+ 1 - 1
src/initgui.js

@@ -366,7 +366,7 @@ window.initgui = async function(){
                 }
                 while(!is_verified)
             }
-            update_auth_data(window.auth_token, whoami);
+            update_auth_data(whoami.token || window.auth_token, whoami);
 
             // -------------------------------------------------------------------------------------
             // Load desktop, only if we're not embedded in a popup