Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ef0ce10
Add account linker form to user profile
LegendBegins Jun 14, 2025
192c9b6
Add CSS for switcher dropdown
LegendBegins Jun 14, 2025
a1e6e53
Added Queries For Linking And Unlinking User Accounts
LegendBegins Jun 14, 2025
02521fa
Inject user alts into findUserBySessionId return object
LegendBegins Jun 15, 2025
2125aa0
Add account switcher button to macros if user has any linked accounts
LegendBegins Jun 15, 2025
bde5515
Add unlink button to account
LegendBegins Jun 15, 2025
106dea3
Add routing support for account link/unlinking
LegendBegins Jun 15, 2025
29ae3e0
Add DB operations to support alt table
LegendBegins Jun 15, 2025
29f132c
Add alts table schema
LegendBegins Jun 15, 2025
20793b9
Replaced variable name
LegendBegins Jun 15, 2025
658f628
Allow staff to see user alts
LegendBegins Jun 15, 2025
6165aaf
Refactor for Typescript migration
LegendBegins Jun 15, 2025
dfad87e
Fixed mods viewing user alts when user has no alts -- Resolving merge…
LegendBegins Jun 15, 2025
71077f3
Removed swap file
LegendBegins Jun 17, 2025
cdf8685
Migrated routes to alts.ts
LegendBegins Jun 17, 2025
ab21e43
Removed auto-focus on Username field in edit_user.html
LegendBegins Jun 17, 2025
f110236
Merge branch 'danneu:master' into Account-Switcher
LegendBegins Jun 18, 2025
c3f47c0
Merge branch 'danneu:master' into Account-Switcher
LegendBegins Sep 13, 2025
4a702b9
Line was removed in commit 09a3b7a and prevents db from resetting
LegendBegins Sep 13, 2025
39d339a
New alts table
LegendBegins Sep 13, 2025
e8cf3ec
Updated linking/unlinking alts to accomodate new schema.
LegendBegins Sep 13, 2025
e030377
Updated comments to support new schema.
LegendBegins Sep 13, 2025
2add103
New alt list injection into user objects
LegendBegins Sep 13, 2025
06fcd1f
Removed alt table generation on registration
LegendBegins Sep 13, 2025
984f520
findUserBySlug injecting alt accounts
LegendBegins Sep 13, 2025
2a0e391
Fixed missing variable index
LegendBegins Sep 13, 2025
802436b
Fixed table name
LegendBegins Sep 13, 2025
cd95482
Fixed user addition query
LegendBegins Sep 13, 2025
916aadc
Added comment explaining alt_group rationale
LegendBegins Sep 13, 2025
29d9fb3
Updated alts dropdown HTML element condition
LegendBegins Sep 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions public/css/general.css
Original file line number Diff line number Diff line change
Expand Up @@ -1498,3 +1498,33 @@ fieldset.cyberpunk {
/* color: #ff6b5c; */
/* text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; */
/* } */



/* Account Switcher */

.dropdown {
position: relative;
display: inline-block;
}

/* Dropdown Content (Hidden by Default) */
.dropdown-content {
display: none;
position: absolute;
top: 28px;
right: 0px;
min-width: 10px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}

/* Links inside the dropdown */
.dropdown-content a {
padding: 2px 10px;
text-decoration: none;
display: block;
}

/* Show the dropdown menu (use JS to add this class to the .dropdown-content container when the user clicks on the dropdown button) */
.dropdown-show {display:block;}
34 changes: 32 additions & 2 deletions server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export async function findUserBySlug(

slug = slug.toLowerCase();

return pool
let user = await pool
.query<DbUser>(
`
SELECT u.*
Expand All @@ -352,7 +352,22 @@ export async function findUserBySlug(
[slug, slug],
)
.then(maybeOneRow);
}
if (user && user.id){
//Get all users from the database where the user ID is any account owned by the same user as our account
const altList = await pool.query(`
SELECT json_agg(u ORDER BY u.uname ASC) AS alts
FROM (
SELECT * FROM users
WHERE alt_group_id = (
SELECT alt_group_id FROM users WHERE id = $1
)
AND id != $1
) u;
`, [user.id]).then(maybeOneRow);
user.alts = altList?.alts ?? [];
}
return user
};

////////////////////////////////////////////////////////////

Expand Down Expand Up @@ -695,6 +710,21 @@ export const findUserBySessionId = async function (sessionId) {
user.roles = pgArray.parse(user.roles, _.identity);
}

if (user && user.id){
//Get all users from the database with the same alt group as the target user
const altList = await pool.query(`
SELECT json_agg(u ORDER BY u.uname ASC) AS alts
FROM (
SELECT * FROM users
WHERE alt_group_id = (
SELECT alt_group_id FROM users WHERE id = $1
)
AND id != $1
) u;
`, [user.id]).then(maybeOneRow);
user.alts = altList?.alts ?? [];
}

return user;
};

Expand Down
39 changes: 39 additions & 0 deletions server/db/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,42 @@ export const approveUser = async ({ approvedBy, targetUser }: { approvedBy: numb
};

////////////////////////////////////////////////////////////

//Updates alts table: First guarantees that the user is part of an alts pool (creating a pool and assigning it to the user if not) then merges the second account and all other accounts in its pool with the first.
//We have to use an empty select (SELECT WHERE (SELECT alt_group_id...) in order to only generate a new alt group on the condition that the user doesn't already have one
export const linkUserAlts = async function(userId: number, altId: number) {
return pool.query(`
WITH current_group AS (
SELECT alt_group_id FROM users WHERE id = $1
),
new_group AS (
INSERT INTO alt_groups
SELECT
WHERE (SELECT alt_group_id FROM current_group) IS NULL
RETURNING id
),
updated_user AS (
UPDATE users
SET alt_group_id = COALESCE(
(SELECT alt_group_id FROM current_group),
(SELECT id FROM new_group)
)
WHERE id = $1
RETURNING alt_group_id
)
UPDATE users
SET alt_group_id = (SELECT alt_group_id FROM updated_user)
WHERE id = $2 OR alt_group_id = (SELECT alt_group_id FROM users WHERE id = $2);
`, [userId, altId]);
};

////////////////////////////////////////////////////////////
//When a user unlinks their account, it removes it from the alt pool but leaves the rest of the pool intact.
export const unlinkUserAlts = async function(userId: number) {
return pool.query(`
UPDATE users
SET alt_group_id = NULL
WHERE id = $1`,
[userId]
);
};
7 changes: 7 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import discordRoutes from "./routes/discord.js";
import searchRoutes from "./routes/search.js";
import topicsRoutes from "./routes/topics.js";
import adminRoutes from "./routes/admin.js";
import altRoutes from "./routes/alts.ts";
import verifyEmailRoutes from "./routes/verify-email.js";
import guildbot from "./guildbot.js";

Expand Down Expand Up @@ -342,6 +343,11 @@ const nunjucksOptions = {
formatChatDate: belt.formatChatDate,
bitAnd: (input, mask) => input & mask,
bitOr: (input, mask) => input | mask,
getPropertyList: (objList, propertyName) => {
return objList.map(function(testObj) {
return testObj[propertyName];
})
},
},
};

Expand Down Expand Up @@ -426,6 +432,7 @@ app.use(searchRoutes.routes());
app.use(topicsRoutes.routes());
app.use(adminRoutes.routes());
app.use(verifyEmailRoutes.routes());
app.use(altRoutes.routes());

// Useful to redirect users to their own profiles since canonical edit-user
// url is /users/:slug/edit
Expand Down
66 changes: 66 additions & 0 deletions server/routes/alts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Router from "@koa/router";
import * as db from "../db";
import * as belt from "../belt";
import { pool } from "../db/util";

const router = new Router();

//
// Remove an account from the alt system
//
router.post('/me/unlink', async ctx => {
ctx.assert(ctx.currUser, 404);
ctx.validateBody('uname').required('Invalid creds (1)');
var newUser = ctx.currUser.uname === ctx.vals.uname ?
ctx.currUser : //If we're unlinking ourselves, no need to check alts
ctx.currUser.alts.filter(alt => {return alt.uname === ctx.vals.uname})[0]; //Filter should only return zero or one item (we set it to that item or undefined)
ctx.assert(newUser, 403); //See if we're unlinking ourselves or any of the current user's alts contain the new account

// User is confirmed to be an alt. Now unlink it
await db.users.unlinkUserAlts(newUser.id);
ctx.flash = { message: ['success', 'Account unlinked successfully'] };
ctx.response.redirect('/');
});

//
// If the login succeeds, link their accounts in the db
//
router.post('/me/link', async ctx => {
ctx.assert(ctx.currUser, 404);
ctx.validateBody('uname-or-email').required('Invalid creds (1)');
ctx.validateBody('password').required('Invalid creds (2)');
var user = await db.findUserByUnameOrEmail(ctx.vals['uname-or-email']);
ctx.check(user, 'Invalid creds (3)');
ctx.check(
await belt.checkPassword(ctx.vals.password, user.digest),
'Invalid creds (4)'
);

// User authenticated. Now connect accounts in the db
await db.users.linkUserAlts(ctx.currUser.id, user.id);
ctx.flash = { message: ['success', 'Account linked successfully'] };
ctx.response.redirect('/');
});

//
// Swap to one of our alts
//
router.post('/swapAccount', async ctx => {
ctx.assert(ctx.currUser && ctx.currUser.alts, 404);
ctx.validateBody('uname').required('Invalid creds (1)');
var newUser = ctx.currUser.alts.filter(alt => {return alt.uname === ctx.vals.uname})[0]; //Filter should only return zero or one item (we set it to that item or undefined)
ctx.assert(newUser, 403); //See if any of the current user's alts contain the new account
await db.logoutSession(ctx.currUser.id, ctx.cookies.get('sessionId')); //End the current session to avoid polluting db
var session = await db.createSession(pool, {
userId: newUser.id,
ipAddress: ctx.request.ip,
interval: '1 year', //If they're using the switcher, they probably want a long-lived session.
});

ctx.cookies.set('sessionId', session.id, {
expires: belt.futureDate({ years: 1 }),
});
ctx.status = 200; //Return OK
});

export default router;
11 changes: 7 additions & 4 deletions server/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,15 @@ router.get(
"/users/:slug/alts",
loadUserFromSlug("slug"),
async (ctx: Context) => {
ctx.body = "temp disabled";
return;

// ctx.assert(ctx.currUser && cancan.isStaffRole(ctx.currUser.role), 403)
ctx.assert(ctx.currUser && cancan.isStaffRole(ctx.currUser.role), 403);
const { user } = ctx.state;

const alts = user.alts ?
user.alts.map(function(altAccount) {return altAccount.uname;})
: []; //await db.hits.findAltsFromUserId(user.id)
return ctx.body = JSON.stringify(alts); //Shortcut to allow staff to see alts. TODO: Prettify

// const { user } = ctx.state

// const alts = await db.hits.findAltsFromUserId(user.id)

Expand Down
4 changes: 3 additions & 1 deletion sql/1-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,10 @@ CREATE INDEX ON posts (ip_address);
CREATE UNIQUE INDEX posts_topic_id_type_idx_idx ON posts (topic_id, type, idx DESC);

-- Last post cache
ALTER TABLE topics ADD COLUMN latest_post_id
ALTER TABLE forums ADD COLUMN latest_post_id
int NULL REFERENCES posts(id) ON DELETE SET NULL;
ALTER TABLE topics ADD COLUMN latest_post_id
int NULL REFERENCES posts(id) ON DELETE SET NULL;
ALTER TABLE topics ADD COLUMN latest_ic_post_id
int NULL REFERENCES posts(id) ON DELETE SET NULL;
ALTER TABLE topics ADD COLUMN latest_ooc_post_id
Expand Down
6 changes: 6 additions & 0 deletions sql/8-alts_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE alt_groups (
id serial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now()
);

ALTER TABLE users ADD COLUMN alt_group_id int NULL REFERENCES alt_groups (id);
63 changes: 60 additions & 3 deletions views/edit_user.html
Original file line number Diff line number Diff line change
Expand Up @@ -649,15 +649,72 @@ <h4>
</form>
{% endif %}

<!-- Account Linker -->
<form action="/me/link" method="post" class="form-horizontal">
<div class="panel panel-default no-select"
style="border-color: #000">
<div class="panel-heading">Link Account</div>
<!-- PANEL BODY -->
<div class="panel-body">

<p>Link another account to switch freely without logging out.</p>

<div class="form-group">
<label for="uname-input"
class="col-sm-3 control-label">
Username/Email:
</label>
<div class="col-sm-9">
<input id="uname-input"
type="text"
name="uname-or-email"
class="form-control"
{% if ctx.flash.params %}
value="{{ ctx.flash.params['uname-or-email'] }}"
{% endif %}
>
</div>
</div>

<div class="form-group">
<label for="password1-input"
class="col-sm-3 control-label">
Password:
</label>
<div class="col-sm-9">
<input id="password1-input"
type="password"
name="password"
class="form-control">
</div>
</div>

<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
</div>
</div>
</div> <!-- /.panel-body -->
<!--
PANEL FOOTER
-->
<div class="panel-footer" style="border-color: #000">
<div class="text-right">
<input id="login-submit"
type="submit"
value="Submit"
class="btn btn-primary">
</div>
</div> <!-- /.panel-footer -->
</div> <!-- /.panel-body -->
</div> <!-- /.panel -->
</form>

</div> <!-- /col -->
</div> <!-- /.row -->

{% endblock %}

{% block scripts %}
<script>
$('#uname-input').focus();
</script>

<script>
$(function() {
Expand Down
11 changes: 11 additions & 0 deletions views/macros/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,18 @@ <h5 class="list-group-item-heading topic-panel-title">
You
{% endif %}
</a>

{% if ctx.currUser.alts.length %}
<button style="width: 35px" name="accounts" onclick='this.querySelector("#accountsElement").classList.toggle("show")' class="btn btn-default navbar-btn dropdown">▼
<div class="dropdown-content" id="accountsElement">
{% for account in ctx.currUser.alts %}
<a href="#" class="btn-default" onclick='fetch("/swapAccount", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({uname:"{{ account.uname }}"})}).then(response => {location.reload()})'>{{ account.uname }}</a>
{% endfor %}
</div>
</button>
{% endif %}
</div>

{% endmacro %}


Expand Down
9 changes: 9 additions & 0 deletions views/show_user.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@
</div>
{% endif %}

<!-- Unlink Button -->
{% if (ctx.currUser) and (ctx.currUser.alts) and ((user.uname in ctx.currUser.alts | getPropertyList('uname')) or ((user.uname == ctx.currUser.uname) and (ctx.currUser.alts.length > 0))) %}
<div class="pull-right" style="margin-right: 10px; display: inline-block;">
<form method="POST" action="/me/unlink" onsubmit="return confirm('Are you sure you want to unlink this account?');">
<button type="submit" name="uname" value="{{ user.uname }}" class="btn btn-danger">Unlink Account</button>
</form>
</div>
{% endif %}

<!-- Nuke Button -->
{% if can(ctx.currUser, 'NUKE_USER', user) %}
<div class="pull-right" style="margin-right: 10px; display: inline-block;">
Expand Down