forked from sk1982/actaeon
add teams editing
This commit is contained in:
parent
35b7e0bb60
commit
f26ff35643
14
db-migrate.cjs
Normal file
14
db-migrate.cjs
Normal file
@ -0,0 +1,14 @@
|
||||
const DBMigrate = require('db-migrate');
|
||||
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
url.searchParams.set('multipleStatements', 'true');
|
||||
process.env.DATABASE_URL = url;
|
||||
|
||||
const dbmigrate = DBMigrate.getInstance(true);
|
||||
|
||||
if (process.argv[2] === 'up')
|
||||
dbmigrate.up();
|
||||
else if (process.argv[2] == 'down')
|
||||
dbmigrate.down();
|
||||
else
|
||||
console.error('Unknown action', argv[2]);
|
53
migrations/20240328113145-modify-team-user-ext-fk.js
Normal file
53
migrations/20240328113145-modify-team-user-ext-fk.js
Normal file
@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
var dbm;
|
||||
var type;
|
||||
var seed;
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var Promise;
|
||||
|
||||
/**
|
||||
* We receive the dbmigrate dependency from dbmigrate initially.
|
||||
* This enables us to not have to rely on NODE_PATH.
|
||||
*/
|
||||
exports.setup = function(options, seedLink) {
|
||||
dbm = options.dbmigrate;
|
||||
type = dbm.dataType;
|
||||
seed = seedLink;
|
||||
Promise = options.Promise;
|
||||
};
|
||||
|
||||
exports.up = function(db) {
|
||||
var filePath = path.join(__dirname, 'sqls', '20240328113145-modify-team-user-ext-fk-up.sql');
|
||||
return new Promise( function( resolve, reject ) {
|
||||
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
|
||||
if (err) return reject(err);
|
||||
console.log('received data: ' + data);
|
||||
|
||||
resolve(data);
|
||||
});
|
||||
})
|
||||
.then(function(data) {
|
||||
return db.runSql(data);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(db) {
|
||||
var filePath = path.join(__dirname, 'sqls', '20240328113145-modify-team-user-ext-fk-down.sql');
|
||||
return new Promise( function( resolve, reject ) {
|
||||
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
|
||||
if (err) return reject(err);
|
||||
console.log('received data: ' + data);
|
||||
|
||||
resolve(data);
|
||||
});
|
||||
})
|
||||
.then(function(data) {
|
||||
return db.runSql(data);
|
||||
});
|
||||
};
|
||||
|
||||
exports._meta = {
|
||||
"version": 1
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
ALTER TABLE actaeon_user_ext
|
||||
DROP CONSTRAINT fk_team;
|
||||
ALTER TABLE actaeon_user_ext
|
||||
ADD CONSTRAINT fk_team FOREIGN KEY(team) REFERENCES actaeon_teams(uuid)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,5 @@
|
||||
ALTER TABLE actaeon_user_ext
|
||||
DROP CONSTRAINT fk_team;
|
||||
ALTER TABLE actaeon_user_ext
|
||||
ADD CONSTRAINT fk_team FOREIGN KEY(team) REFERENCES actaeon_teams(uuid)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -34,7 +34,7 @@ const nextConfig = {
|
||||
},
|
||||
productionBrowserSourceMaps: true,
|
||||
webpack: config => {
|
||||
config.externals = [...config.externals, 'bcrypt'];
|
||||
config.externals = [...config.externals, 'bcrypt', 'mysql2'];
|
||||
return config;
|
||||
}
|
||||
};
|
||||
|
@ -8,9 +8,9 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"migrate:up": "dotenvx run -f .env.local -- db-migrate up",
|
||||
"migrate:up": "dotenvx run -f .env.local -- node db-migrate.cjs up",
|
||||
"migrate": "npm run migrate:up",
|
||||
"migrate:down": "dotenvx run -f .env.local -- db-migrate down",
|
||||
"migrate:down": "dotenvx run -f .env.local -- node db-migrate.cjs down",
|
||||
"migrate:create": "dotenvx run -f .env.local -- db-migrate create",
|
||||
"db:export": "dotenvx run -f .env.local -- kysely-codegen --exclude-pattern cozynet* --exclude-pattern alembic_version --exclude-pattern actaeon_migrations --out-file src/types/db.d.ts && node process-db.js"
|
||||
},
|
||||
|
224
src/actions/team.ts
Normal file
224
src/actions/team.ts
Normal file
@ -0,0 +1,224 @@
|
||||
'use server';
|
||||
|
||||
import { ActionResult } from '@/types/action-result';
|
||||
import { requireUser } from './auth';
|
||||
import { db } from '@/db';
|
||||
import crypto from 'crypto';
|
||||
import { JoinPrivacy, PRIVACY_VALUES, VISIBILITY_VALUES, Visibility } from '@/types/privacy-visibility';
|
||||
import { syncUserTeams } from '@/data/team';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { UserPermissions } from '@/types/permissions';
|
||||
import { hasPermission, requirePermission } from '@/helpers/permissions';
|
||||
import { makeValidator } from '@/types/validator-map';
|
||||
import { DB } from '@/types/db';
|
||||
import { randomString } from '@/helpers/random';
|
||||
|
||||
export const createTeam = async (name: string): Promise<ActionResult> => {
|
||||
const user = await requireUser();
|
||||
|
||||
if (user.team)
|
||||
return { error: true, message: 'You are already part of a team' };
|
||||
|
||||
name = name.trim();
|
||||
const existingTeam = await db.selectFrom('actaeon_teams')
|
||||
.where(({ fn, eb }) => eb(fn('lower', ['name']), '=', name.toLowerCase()))
|
||||
.select('name')
|
||||
.executeTakeFirst();
|
||||
|
||||
if (existingTeam)
|
||||
return { error: true, message: 'A team with that name already exists' };
|
||||
|
||||
const uuid = crypto.randomUUID();
|
||||
|
||||
await db.transaction().execute(async trx => {
|
||||
const chuniTeam = Number((await db.insertInto('chuni_profile_team')
|
||||
.values({
|
||||
teamName: name,
|
||||
teamPoint: 0
|
||||
})
|
||||
.executeTakeFirst()).insertId);
|
||||
|
||||
await db.insertInto('actaeon_teams')
|
||||
.values({
|
||||
uuid,
|
||||
name,
|
||||
visibility: Visibility.PRIVATE,
|
||||
joinPrivacy: JoinPrivacy.INVITE_ONLY,
|
||||
owner: user.id!,
|
||||
chuniTeam
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
await db.updateTable('actaeon_user_ext')
|
||||
.where('userId', '=', user.id)
|
||||
.set({ team: uuid } )
|
||||
.executeTakeFirst();
|
||||
|
||||
await syncUserTeams(user.id, { chuniTeam }, trx);
|
||||
});
|
||||
|
||||
redirect(`/team/${uuid}`);
|
||||
};
|
||||
|
||||
const requireOwner = async ({ team, orPermission, teamData }: {
|
||||
team: string,
|
||||
orPermission?: UserPermissions,
|
||||
teamData?: DB['actaeon_teams']
|
||||
}) => {
|
||||
const user = await requireUser();
|
||||
|
||||
if (!teamData)
|
||||
teamData = await db.selectFrom('actaeon_teams')
|
||||
.where('uuid', '=', team)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!teamData)
|
||||
notFound();
|
||||
|
||||
if (hasPermission(user.permissions, orPermission ?? UserPermissions.OWNER))
|
||||
return teamData;
|
||||
|
||||
if (teamData.owner !== user.id)
|
||||
redirect('/forbidden');
|
||||
|
||||
return teamData;
|
||||
}
|
||||
|
||||
export type TeamUpdate = Partial<{
|
||||
name: string,
|
||||
joinPrivacy: JoinPrivacy,
|
||||
visibility: Visibility
|
||||
}>;
|
||||
|
||||
const validator = makeValidator<TeamUpdate, DB['actaeon_teams'] | null>()
|
||||
.nonNullableKeys('name', 'joinPrivacy', 'visibility')
|
||||
.withValidator('name', async (val, team) => {
|
||||
val = val.trim();
|
||||
|
||||
const existingTeam = await db.selectFrom('actaeon_teams')
|
||||
.where(({ fn, eb, and }) => and([
|
||||
eb(fn('lower', ['name']), '=', val.toLowerCase()),
|
||||
...(team ? [eb('uuid', '!=', team.uuid)] : [])
|
||||
]))
|
||||
.select('uuid')
|
||||
.executeTakeFirst();
|
||||
|
||||
if (existingTeam)
|
||||
throw new Error('A team with that name already exists');
|
||||
|
||||
return val;
|
||||
})
|
||||
.withValidator('joinPrivacy', val => {
|
||||
if (!PRIVACY_VALUES.has(val))
|
||||
throw new Error('Invalid privacy value');
|
||||
})
|
||||
.withValidator('visibility', val => {
|
||||
if (!VISIBILITY_VALUES.has(val))
|
||||
throw new Error('Invalid visibility value');
|
||||
});
|
||||
|
||||
export const modifyTeam = async (team: string, update: TeamUpdate): Promise<ActionResult<{}>> => {
|
||||
const teamData = await requireOwner({ team });
|
||||
const res = await validator.validate(update, teamData);
|
||||
if (res.error) return res;
|
||||
|
||||
await db.updateTable('actaeon_teams')
|
||||
.where('uuid', '=', team)
|
||||
.set(update)
|
||||
.executeTakeFirst();
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const joinPublicTeam = async (team: string) => {
|
||||
const user = await requireUser();
|
||||
const teamData = await db.selectFrom('actaeon_teams')
|
||||
.where('uuid', '=', team)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!teamData)
|
||||
return notFound();
|
||||
|
||||
if (teamData.joinPrivacy !== JoinPrivacy.PUBLIC)
|
||||
return requirePermission(user.permissions, UserPermissions.OWNER);
|
||||
|
||||
if (user.team)
|
||||
return { error: true, message: 'You are already part of a team' };
|
||||
|
||||
await db.transaction().execute(async trx => {
|
||||
await trx.updateTable('actaeon_user_ext')
|
||||
.where('userId', '=', user.id)
|
||||
.set({ team })
|
||||
.executeTakeFirst();
|
||||
|
||||
await syncUserTeams(user.id, teamData, trx);
|
||||
});
|
||||
};
|
||||
|
||||
export const removeUserFromTeam = async (team: string, userId?: number) => {
|
||||
const user = await requireUser();
|
||||
userId ??= user.id;
|
||||
|
||||
const teamData = await db.selectFrom('actaeon_teams')
|
||||
.where('uuid', '=', team)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!teamData) return notFound()
|
||||
|
||||
if (userId === teamData.owner)
|
||||
return { error: true, message: 'The owner of this team cannot be removed' };
|
||||
|
||||
if (user.id !== userId)
|
||||
await requireOwner({ team, teamData, orPermission: UserPermissions.USERMOD });
|
||||
|
||||
await db.transaction().execute(async trx => {
|
||||
await trx.updateTable('actaeon_user_ext')
|
||||
.where('userId', '=', userId)
|
||||
.where('team', '=', team)
|
||||
.set({ team: null })
|
||||
.executeTakeFirst();
|
||||
|
||||
await syncUserTeams(userId, null, trx);
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTeam = async (team: string) => {
|
||||
const teamData = await requireOwner({ team });
|
||||
|
||||
await db.transaction().execute(async trx => {
|
||||
await trx.deleteFrom('chuni_profile_team')
|
||||
.where('id', '=', teamData.chuniTeam)
|
||||
.executeTakeFirst();
|
||||
|
||||
await trx.deleteFrom('actaeon_teams')
|
||||
.where('uuid', '=', teamData.uuid)
|
||||
.executeTakeFirst();
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTeamLink = async (team: string, link: string) => {
|
||||
const teamData = await requireOwner({ team });
|
||||
await db.deleteFrom('actaeon_team_join_keys')
|
||||
.where('teamId', '=', teamData.uuid)
|
||||
.where('id', '=', link)
|
||||
.executeTakeFirst();
|
||||
};
|
||||
|
||||
export const createTeamLink = async (team: string, remainingUses: number | null) => {
|
||||
const teamData = await requireOwner({ team });
|
||||
const id = randomString(10);
|
||||
|
||||
await db.insertInto('actaeon_team_join_keys')
|
||||
.values({
|
||||
id,
|
||||
teamId: team,
|
||||
remainingUses,
|
||||
totalUses: 0
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
return id;
|
||||
};
|
@ -1,10 +1,10 @@
|
||||
import { Arcade, getArcades } from '@/data/arcade';
|
||||
import { getUser } from '@/actions/auth';
|
||||
import { Divider, Tooltip } from '@nextui-org/react';
|
||||
import { GlobeAltIcon, LinkIcon, LockClosedIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
import { Visibility } from '@/types/privacy-visibility';
|
||||
import { UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { CreateArcadeButton } from '@/components/create-arcade-button';
|
||||
import { VisibilityIcon } from '@/components/visibility-icon';
|
||||
|
||||
const getLocation = (arcade: Arcade) => {
|
||||
let out = [arcade.city, arcade.state, arcade.country].filter(x => x).join(', ');
|
||||
@ -28,15 +28,7 @@ export default async function ArcadePage() {
|
||||
|
||||
{arcades.map(arcade => <article key={arcade.uuid}
|
||||
className="flex p-4 bg-content1 rounded gap-2 items-center flex-wrap text-xs sm:text-sm md:text-medium">
|
||||
{arcade.visibility === Visibility.PUBLIC && <Tooltip content="Public">
|
||||
<GlobeAltIcon className="h-6" />
|
||||
</Tooltip>}
|
||||
{arcade.visibility === Visibility.UNLISTED && <Tooltip content="Unlisted">
|
||||
<LinkIcon className="h-6" />
|
||||
</Tooltip>}
|
||||
{arcade.visibility === Visibility.PRIVATE && <Tooltip content="Private">
|
||||
<LockClosedIcon className="h-6" />
|
||||
</Tooltip>}
|
||||
<VisibilityIcon visibility={arcade.visibility} className="h-6" />
|
||||
<header className="text-lg font-semibold">
|
||||
{arcade.uuid ? <Link href={`/arcade/${arcade.uuid}`}
|
||||
className="hover:text-secondary transition">{arcade.name}</Link> : arcade.name}
|
||||
|
60
src/app/(with-header)/team/[teamId]/join/[join]/page.tsx
Normal file
60
src/app/(with-header)/team/[teamId]/join/[join]/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { requireUser } from '@/actions/auth';
|
||||
import { InvalidLink } from '@/components/invalid-link';
|
||||
import { JoinSuccess } from '@/components/join-success';
|
||||
import { syncUserTeams } from '@/data/team';
|
||||
import { db } from '@/db';
|
||||
import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Join({ params }: { params: { teamId: string, join: string; }; }) {
|
||||
const user = await requireUser();
|
||||
|
||||
if (!params.join)
|
||||
return (<InvalidLink />);
|
||||
|
||||
if (user.team === params.teamId)
|
||||
return redirect(`/team/${params.teamId}`)
|
||||
|
||||
if (user.team)
|
||||
return (<main className="flex flex-col w-full m-auto items-center gap-4 pb-10 text-center">
|
||||
<ExclamationCircleIcon className="w-48 h-48 mb-10" />
|
||||
<header className="text-2xl font-semibold">You are already part of a team.</header>
|
||||
<span>You must leave your current team before joining a new one.</span>
|
||||
</main>);
|
||||
|
||||
const joinLink = await db.selectFrom('actaeon_teams as team')
|
||||
.innerJoin('actaeon_team_join_keys as key', 'key.teamId', 'team.uuid')
|
||||
.where('team.uuid', '=', params.teamId)
|
||||
.where('key.id', '=', params.join)
|
||||
.select(['key.teamId', 'key.remainingUses', 'key.totalUses', 'key.id', 'team.chuniTeam'])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!joinLink)
|
||||
return (<InvalidLink />);
|
||||
|
||||
await db.transaction().execute(async trx => {
|
||||
await trx.updateTable('actaeon_user_ext')
|
||||
.where('userId', '=', user.id)
|
||||
.set({ team: params.teamId })
|
||||
.executeTakeFirst();
|
||||
|
||||
if (joinLink.remainingUses !== null && joinLink.remainingUses <= 1)
|
||||
await trx.deleteFrom('actaeon_team_join_keys')
|
||||
.where('id', '=', joinLink.id)
|
||||
.executeTakeFirst();
|
||||
else
|
||||
await trx.updateTable('actaeon_team_join_keys')
|
||||
.where('id', '=', joinLink.id)
|
||||
.set({
|
||||
totalUses: joinLink.totalUses + 1,
|
||||
...(joinLink.remainingUses ? {
|
||||
remainingUses: joinLink.remainingUses - 1
|
||||
} : {})
|
||||
})
|
||||
.executeTakeFirst()
|
||||
|
||||
await syncUserTeams(user.id, joinLink, trx);
|
||||
});
|
||||
|
||||
return (<JoinSuccess href={`/team/${params.teamId}`} />)
|
||||
}
|
23
src/app/(with-header)/team/[teamId]/page.tsx
Normal file
23
src/app/(with-header)/team/[teamId]/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { getUser } from '@/actions/auth';
|
||||
import { getTeamInviteLinks, getTeams, getTeamUsers } from '@/data/team';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PrivateVisibilityError } from '@/components/private-visibility-error';
|
||||
import { TeamDetail } from '@/components/team';
|
||||
|
||||
export default async function TeamDetailPage({ params }: { params: { teamId: string }}) {
|
||||
const user = await getUser();
|
||||
const team = (await getTeams({ user, uuids: [params.teamId], showUnlisted: true }))[0];
|
||||
|
||||
if (!team)
|
||||
return notFound();
|
||||
|
||||
const [users, links] = await Promise.all([
|
||||
getTeamUsers({ user, team }),
|
||||
getTeamInviteLinks({ user, team })
|
||||
]);
|
||||
|
||||
if (!team.visible)
|
||||
return <PrivateVisibilityError />;
|
||||
|
||||
return (<TeamDetail team={team} users={users} links={links} />)
|
||||
}
|
53
src/app/(with-header)/team/page.tsx
Normal file
53
src/app/(with-header)/team/page.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { getUser } from '@/actions/auth';
|
||||
import { CreateTeamButton } from '@/components/create-team-button';
|
||||
import { VisibilityIcon } from '@/components/visibility-icon';
|
||||
import { getTeams } from '@/data/team';
|
||||
import { UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon } from '@heroicons/react/24/solid';
|
||||
import { Divider, Tooltip } from '@nextui-org/react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function TeamPage() {
|
||||
const user = await getUser();
|
||||
const teams = (await getTeams({ user }))
|
||||
.filter(t => t.visible);
|
||||
|
||||
return (<main className="flex flex-col max-w-5xl mx-auto w-full">
|
||||
<header className="font-semibold flex items-center text-2xl p-4">
|
||||
Teams
|
||||
|
||||
{!teams[0]?.isMember && <CreateTeamButton />}
|
||||
</header>
|
||||
|
||||
<Divider className="hidden sm:block" />
|
||||
|
||||
<section className="w-full px-1 sm:p-0 sm:mt-4 flex flex-col gap-2 mx-auto">
|
||||
{!teams.length && <span className="italic text-gray-500 ml-2">No teams found</span>}
|
||||
|
||||
{teams.map(team => <article key={team.uuid}
|
||||
className="flex p-4 bg-content1 rounded gap-2 items-center flex-wrap text-xs sm:text-sm md:text-medium">
|
||||
<VisibilityIcon visibility={team.visibility} className="h-6" />
|
||||
<Link href={`/team/${team.uuid}`} className="font-semibold transition hover:text-secondary">
|
||||
{team.name}
|
||||
</Link>
|
||||
{team.isMember && <Tooltip content="Your team">
|
||||
<StarIcon className="h-6 text-amber-400" />
|
||||
</Tooltip>}
|
||||
|
||||
<Tooltip content={`${team.userCount} User${team.userCount === 1 ? '' : 's'}`}>
|
||||
<div className="flex gap-2 mr-3 items-center ml-auto">
|
||||
<UserGroupIcon className="w-6" />
|
||||
<span>{team.userCount?.toString()}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<span>
|
||||
Leader:
|
||||
{team.ownerUsername ? <Link href={`/user/${team.ownerUuid}`} className="font-semibold transition hover:text-secondary">
|
||||
{team.ownerUsername}
|
||||
</Link> : <span className="italic text-gray-500">anonymous user</span>}
|
||||
</span>
|
||||
</article>)}
|
||||
</section>
|
||||
</main>);
|
||||
}
|
@ -20,6 +20,8 @@ import { XMarkIcon } from '@heroicons/react/20/solid';
|
||||
import { JoinLinksModal } from '@/components/join-links-modal';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { PermissionEditModal, PermissionEditModalUser } from '@/components/permission-edit-modal';
|
||||
import { VisibilityIcon } from './visibility-icon';
|
||||
import { VisibilityDropdown } from './visibility-dropdown';
|
||||
|
||||
export type ArcadeProps = {
|
||||
arcade: Arcade,
|
||||
@ -136,17 +138,6 @@ export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs:
|
||||
onChange={ev => setArcade(a => ({ ...a, [k]: ev.target.value }))} />);
|
||||
};
|
||||
|
||||
const visibilityIcon = (<>
|
||||
{arcade.visibility === Visibility.PUBLIC && <Tooltip content="Public">
|
||||
<GlobeAltIcon className="h-8" />
|
||||
</Tooltip>}
|
||||
{arcade.visibility === Visibility.UNLISTED && <Tooltip content="Unlisted">
|
||||
<LinkIcon className="h-8" />
|
||||
</Tooltip>}
|
||||
{arcade.visibility === Visibility.PRIVATE && <Tooltip content="Private">
|
||||
<LockClosedIcon className="h-8" />
|
||||
</Tooltip>}</>);
|
||||
|
||||
return (<main className="w-full flex flex-col mt-2">
|
||||
<JoinLinksModal links={links} prefix={`/arcade/${arcade.uuid}/join/`}
|
||||
onDelete={id => deleteArcadeLink(arcade.id, id)}
|
||||
@ -163,42 +154,18 @@ export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs:
|
||||
setUserArcadePermissions({ arcadeUser: user, permissions, arcade: arcade.id });
|
||||
}} />
|
||||
<header className="font-bold text-5xl self-center flex gap-3 items-center">
|
||||
{editing ?
|
||||
<>
|
||||
<Dropdown isDisabled={loading}>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light" size="lg" className="ml-2 w-20">
|
||||
{visibilityIcon}
|
||||
<ChevronDownIcon className="w-7" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu selectionMode="single" selectedKeys={new Set([arcade.visibility.toString()])}
|
||||
onSelectionChange={s => typeof s !== 'string' && s.size && setArcade(
|
||||
a => ({ ...a, visibility: +[...s][0] }))}>
|
||||
<DropdownItem key={Visibility.PRIVATE} description="Visible only to arcade members"
|
||||
startContent={<LockClosedIcon className="h-6" />}>
|
||||
Private
|
||||
</DropdownItem>
|
||||
<DropdownItem key={Visibility.UNLISTED}
|
||||
description="Visible to those who have the link to this page"
|
||||
startContent={<LinkIcon className="h-6" />}>
|
||||
Unlisted
|
||||
</DropdownItem>
|
||||
<DropdownItem key={Visibility.PUBLIC} description="Visible to everyone"
|
||||
startContent={<GlobeAltIcon className="h-6" />}>
|
||||
Public
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<Input aria-label="Name" size="lg" className="font-normal mr-2" labelPlacement="outside-left" type="text"
|
||||
isDisabled={loading} isRequired placeholder="Name"
|
||||
value={arcade.name ?? ''} onChange={ev => setArcade(a => ({ ...a, name: ev.target.value }))}
|
||||
classNames={{
|
||||
input: 'text-4xl leading-none',
|
||||
inputWrapper: 'h-24'
|
||||
}} />
|
||||
</> :
|
||||
<>{visibilityIcon} {arcade.name}</>}
|
||||
<VisibilityDropdown visibility={arcade.visibility} editing={editing} loading={loading}
|
||||
onVisibilityChange={v => setArcade(a => ({ ...a, visibility: v }))} />
|
||||
{editing ?
|
||||
<Input aria-label="Name" size="lg" className="font-normal mr-2" labelPlacement="outside-left" type="text"
|
||||
isDisabled={loading} isRequired placeholder="Name"
|
||||
value={arcade.name ?? ''} onChange={ev => setArcade(a => ({ ...a, name: ev.target.value }))}
|
||||
classNames={{
|
||||
input: 'text-4xl leading-none',
|
||||
inputWrapper: 'h-24'
|
||||
}} />
|
||||
:
|
||||
arcade.name}
|
||||
</header>
|
||||
{editing ? <section
|
||||
className="grid px-2 sm:px-0 grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-5 5xl:grid-cols-9 gap-2 flex-wrap mt-3">
|
||||
@ -271,7 +238,7 @@ export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs:
|
||||
<header className="py-4 pl-4 sm:pl-0 flex items-center text-2xl font-semibold">
|
||||
<span className="mr-auto">Users</span>
|
||||
|
||||
{arcade.joinPrivacy === JoinPrivacy.PUBLIC && !arcade.permissions && <Tooltip content="Join this arcade">
|
||||
{(arcade.joinPrivacy === JoinPrivacy.PUBLIC || hasPermission(user?.permissions, UserPermissions.ACMOD)) && !arcade.permissions && <Tooltip content="Join this arcade">
|
||||
<Button className="mr-2" isIconOnly size="lg" onPress={() => joinPublicArcade(arcade.id)
|
||||
.then(() => location.reload())}>
|
||||
<UserPlusIcon className="h-1/2" />
|
||||
|
@ -38,14 +38,14 @@ export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
|
||||
<ModalFooter className="gap-2">
|
||||
<Button onPress={() => {
|
||||
if (cancelCallback.current)
|
||||
setTimeout(cancelCallback.current, 15);
|
||||
setTimeout(cancelCallback.current, 100);
|
||||
onClose();
|
||||
}} >
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onPress={() => {
|
||||
if (confirmCallback.current)
|
||||
setTimeout(confirmCallback.current, 15);
|
||||
setTimeout(confirmCallback.current, 100);
|
||||
onClose();
|
||||
}} color="danger">
|
||||
Confirm
|
||||
|
30
src/components/create-team-button.tsx
Normal file
30
src/components/create-team-button.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Tooltip } from '@nextui-org/react';
|
||||
import { usePromptModal } from './prompt-modal';
|
||||
import { useErrorModal } from './error-modal';
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { useUser } from '@/helpers/use-user';
|
||||
import { createTeam } from '@/actions/team';
|
||||
|
||||
export const CreateTeamButton = () => {
|
||||
const prompt = usePromptModal();
|
||||
const setError = useErrorModal();
|
||||
const user = useUser();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (<Tooltip content="Create new team">
|
||||
<Button isIconOnly className="ml-auto" onPress={() => prompt({
|
||||
title: 'Enter name', message: 'Enter a name for this team',
|
||||
label: 'Name'
|
||||
}, val => {
|
||||
if (!val)
|
||||
return setError('Name is required');
|
||||
createTeam(val)
|
||||
.then(res => res?.error && setError(res.message));
|
||||
})}>
|
||||
<PlusIcon className="h-3/4" />
|
||||
</Button>
|
||||
</Tooltip>);
|
||||
};
|
@ -151,7 +151,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
|
||||
<div className="mr-auto mt-1 hidden md:flex text-lg">
|
||||
{routeGroup.routes?.filter(filter).map(renderHeaderLink)}
|
||||
</div>
|
||||
{routeGroup !== MAIN_ROUTES && <div className="mr-4 mt-1 hidden [@media(min-width:1080px)]:flex text-lg">
|
||||
{routeGroup !== MAIN_ROUTES && <div className="mr-4 mt-1 hidden [@media(min-width:1175px)]:flex text-lg">
|
||||
{MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)}
|
||||
</div>}
|
||||
<div className="hidden md:flex">
|
||||
|
@ -56,7 +56,7 @@ export const PromptProvider = ({ children }: { children: ReactNode }) => {
|
||||
</Button>
|
||||
<Button onPress={() => {
|
||||
if (confirmCallback.current)
|
||||
setTimeout(confirmCallback.current, 15, value ?? '');
|
||||
setTimeout(confirmCallback.current, 100, value ?? '');
|
||||
onClose();
|
||||
}} color="primary">
|
||||
Confirm
|
||||
|
194
src/components/team.tsx
Normal file
194
src/components/team.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Team, TeamUser } from '@/data/team';
|
||||
import { VisibilityDropdown } from './visibility-dropdown';
|
||||
import { Button, Divider, Input, Select, SelectItem, Tooltip } from '@nextui-org/react';
|
||||
import { JoinPrivacy } from '@/types/privacy-visibility';
|
||||
import { LinkIcon, PencilIcon, UserMinusIcon, UserPlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useUser } from '@/helpers/use-user';
|
||||
import { createTeamLink, deleteTeam, deleteTeamLink, joinPublicTeam, modifyTeam, removeUserFromTeam } from '@/actions/team';
|
||||
import { useErrorModal } from './error-modal';
|
||||
import { UserPermissions } from '@/types/permissions';
|
||||
import { hasPermission } from '@/helpers/permissions';
|
||||
import { useConfirmModal } from './confirm-modal';
|
||||
import { DB } from '@/types/db';
|
||||
import { JoinLinksModal } from './join-links-modal';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type TeamDetailProps = {
|
||||
team: Team,
|
||||
users: TeamUser[],
|
||||
links: DB['actaeon_team_join_keys'][]
|
||||
};
|
||||
|
||||
export const TeamDetail = ({ team: initialTeam, users: initialUsers, links }: TeamDetailProps) => {
|
||||
const [team, setTeam] = useState(initialTeam);
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [linksOpen, setLinksOpen] = useState(false);
|
||||
const teamRestore = useRef({ ...team });
|
||||
const user = useUser();
|
||||
const setError = useErrorModal();
|
||||
const confirm = useConfirmModal();
|
||||
const router = useRouter();
|
||||
|
||||
const save = () => {
|
||||
setLoading(true);
|
||||
modifyTeam(team.uuid, {
|
||||
name: team.name!,
|
||||
visibility: team.visibility,
|
||||
joinPrivacy: team.joinPrivacy
|
||||
})
|
||||
.then(res => {
|
||||
if (res.error)
|
||||
return setError(res.message);
|
||||
setEditing(false);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const isMember = !!users.find(u => user && u.uuid === user?.uuid);
|
||||
const isOwner = hasPermission(user?.permissions, UserPermissions.OWNER) || (user && user.uuid === team.ownerUuid);
|
||||
|
||||
return (<main className="w-full flex flex-col mt-2">
|
||||
<JoinLinksModal links={links} prefix={`/team/${team.uuid}/join/`}
|
||||
onDelete={id => deleteTeamLink(team.uuid, id)}
|
||||
onCreate={uses => createTeamLink(team.uuid, uses)}
|
||||
open={linksOpen} onClose={() => setLinksOpen(false)} />
|
||||
|
||||
<header className="font-bold text-5xl self-center flex gap-3 items-center">
|
||||
<VisibilityDropdown visibility={team.visibility} editing={editing} loading={loading}
|
||||
onVisibilityChange={v => setTeam(t => ({ ...t, visibility: v }))} />
|
||||
{editing ? <Input aria-label="Name" size="lg" className="font-normal mr-2" labelPlacement="outside-left" type="text"
|
||||
isDisabled={loading} isRequired placeholder="Name"
|
||||
value={team.name ?? ''} onChange={ev => setTeam(t => ({ ...t, name: ev.target.value }))}
|
||||
classNames={{
|
||||
input: 'text-4xl leading-none',
|
||||
inputWrapper: 'h-24'
|
||||
}} /> : team.name}
|
||||
</header>
|
||||
|
||||
<section className={`flex ${editing ? 'flex-col gap-x-2' : 'gap-x-10'} sm:flex-row w-full justify-center mt-5 gap-y-3 text-xl items-center`}>
|
||||
{editing ? <>
|
||||
<Select isRequired label="Join Privacy" selectedKeys={new Set([team.joinPrivacy.toString()])} className="w-full px-2 sm:w-60 sm:ml-14"
|
||||
isDisabled={loading} size="sm"
|
||||
onSelectionChange={s => typeof s !== 'string' && s.size && setTeam(t => ({ ...t, joinPrivacy: +[...s][0] }))}>
|
||||
<SelectItem key={JoinPrivacy.INVITE_ONLY.toString()}>Invite Only</SelectItem>
|
||||
<SelectItem key={JoinPrivacy.PUBLIC.toString()}>Public</SelectItem>
|
||||
</Select>
|
||||
|
||||
<div className="h-full flex gap-2 min-h-12 ml-auto mr-2 sm:m-0">
|
||||
<Button className="h-full" variant="light" color="danger" onPress={() => {
|
||||
setTeam(teamRestore.current);
|
||||
setEditing(false);
|
||||
}}>Cancel</Button>
|
||||
<Button className="h-full" color="primary" onPress={save}>Save</Button>
|
||||
</div>
|
||||
</> : <>
|
||||
<div>
|
||||
<span className="font-semibold">Chunithm Team Points: </span>
|
||||
{team.chuniTeamPoint?.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-semibold">Join Privacy: </span>
|
||||
{team.joinPrivacy === JoinPrivacy.PUBLIC ? 'Public' : 'Invite Only'}
|
||||
</div>
|
||||
|
||||
{isOwner && <Tooltip content="Edit team settings">
|
||||
<Button isIconOnly variant="light" radius="full" onPress={() => {
|
||||
setEditing(true);
|
||||
teamRestore.current = { ...team };
|
||||
}}>
|
||||
<PencilIcon className="h-1/2" />
|
||||
</Button>
|
||||
</Tooltip>}
|
||||
</>}
|
||||
|
||||
</section>
|
||||
|
||||
<Divider className="mt-4" />
|
||||
|
||||
<section className="max-w-screen-4xl w-full mx-auto">
|
||||
<header className="py-4 pl-4 sm:pl-0 flex items-center text-2xl font-semibold">
|
||||
<span className="mr-auto">Users</span>
|
||||
|
||||
{(team.joinPrivacy === JoinPrivacy.PUBLIC || isOwner) && !isMember &&
|
||||
!user?.team && <Tooltip content="Join this team">
|
||||
<Button className="mr-2" isIconOnly size="lg" onPress={() => joinPublicTeam(team.uuid).then(res => {
|
||||
if (res?.error) return setError(res.message);
|
||||
location.reload();
|
||||
})}>
|
||||
<UserPlusIcon className="h-1/2" />
|
||||
</Button>
|
||||
</Tooltip>}
|
||||
|
||||
{!isOwner && isMember && <Tooltip content={<span className="text-danger">Leave this team</span>}>
|
||||
<Button className="mr-2" isIconOnly size="lg" variant="flat" color="danger" onPress={() => {
|
||||
confirm('Would you like to leave this team?', () => {
|
||||
removeUserFromTeam(team.uuid)
|
||||
.then(res => {
|
||||
if (res?.error) return setError(res.message);
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<UserMinusIcon className="h-1/2" />
|
||||
</Button>
|
||||
</Tooltip>}
|
||||
|
||||
{isOwner && <Tooltip content="Manage invite links">
|
||||
<Button className="mr-2" isIconOnly size="lg" onPress={() => setLinksOpen(true)}>
|
||||
<LinkIcon className="h-1/2" />
|
||||
</Button>
|
||||
</Tooltip>}
|
||||
</header>
|
||||
|
||||
{!users.length && <span className="italic text-gray-500">This arcade has no users</span>}
|
||||
|
||||
<div className="flex flex-wrap gap-3 px-2 sm:px-0">
|
||||
{users.map((teamUser, index) => <div key={index}
|
||||
className="p-3 bg-content1 shadow w-full sm:w-64 max-w-full h-16 overflow-hidden flex items-center rounded-lg gap-1">
|
||||
{teamUser.uuid ? <Link className="font-semibold underline transition hover:text-secondary mr-auto"
|
||||
href={`/user/${teamUser.uuid}`}>
|
||||
{teamUser.username}
|
||||
</Link> : <span className="text-gray-500 italic mr-auto">Anonymous User</span>}
|
||||
|
||||
{isOwner && teamUser.uuid !== user?.uuid && <Tooltip content={<span className="text-danger">Kick user</span>}>
|
||||
<Button isIconOnly color="danger" size="sm" onPress={() => {
|
||||
confirm('Are you sure you want to kick this user?',
|
||||
() => removeUserFromTeam(team.uuid, teamUser.id!)
|
||||
.then(() => setUsers(u => u.filter(u => u.uuid !== teamUser.uuid))));
|
||||
}}>
|
||||
<XMarkIcon className="h-1/2" />
|
||||
</Button>
|
||||
</Tooltip>}
|
||||
</div>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isOwner && <>
|
||||
<Divider className="mt-4" />
|
||||
|
||||
<section className="max-w-screen-4xl w-full mx-auto">
|
||||
<header className="py-4 pl-4 sm:pl-0 flex items-center text-2xl font-semibold">
|
||||
Management
|
||||
</header>
|
||||
|
||||
<Button color="danger" onPress={() => {
|
||||
confirm(<span>Do you want to delete this team? This will remove all team data such as team points.
|
||||
<span className="font-bold">THIS ACTION CANNOT BE UNDONE.</span></span>, () =>
|
||||
confirm(<span>Are you <span className="font-bold">REALLY</span> sure?</span>, () => {
|
||||
deleteTeam(team.uuid)
|
||||
.then(() => { router.push('/team'); router.refresh() });
|
||||
}));
|
||||
}}>
|
||||
Delete this team
|
||||
</Button>
|
||||
</section>
|
||||
</>}
|
||||
</main>);
|
||||
};
|
43
src/components/visibility-dropdown.tsx
Normal file
43
src/components/visibility-dropdown.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Visibility } from '@/types/privacy-visibility';
|
||||
import { VisibilityIcon } from './visibility-icon';
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/react';
|
||||
import { ChevronDownIcon, GlobeAltIcon, LinkIcon, LockClosedIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
type VisibilityDropdownProps = {
|
||||
visibility: Visibility,
|
||||
editing: boolean,
|
||||
loading: boolean,
|
||||
onVisibilityChange: (visibility: Visibility) => void
|
||||
};
|
||||
|
||||
export const VisibilityDropdown = ({ visibility, editing, loading, onVisibilityChange }: VisibilityDropdownProps) => {
|
||||
const icon = (<VisibilityIcon visibility={visibility} className="h-8" />);
|
||||
|
||||
if (!editing)
|
||||
return icon;
|
||||
|
||||
return (<Dropdown isDisabled={loading}>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light" size="lg" className="ml-2 w-20">
|
||||
{icon}
|
||||
<ChevronDownIcon className="w-7" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu selectionMode="single" selectedKeys={new Set([visibility.toString()])}
|
||||
onSelectionChange={s => typeof s !== 'string' && s.size && onVisibilityChange(+[...s][0] as any)}>
|
||||
<DropdownItem key={Visibility.PRIVATE} description="Visible only to arcade members"
|
||||
startContent={<LockClosedIcon className="h-6" />}>
|
||||
Private
|
||||
</DropdownItem>
|
||||
<DropdownItem key={Visibility.UNLISTED}
|
||||
description="Visible to those who have the link to this page"
|
||||
startContent={<LinkIcon className="h-6" />}>
|
||||
Unlisted
|
||||
</DropdownItem>
|
||||
<DropdownItem key={Visibility.PUBLIC} description="Visible to everyone"
|
||||
startContent={<GlobeAltIcon className="h-6" />}>
|
||||
Public
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>);
|
||||
};
|
19
src/components/visibility-icon.tsx
Normal file
19
src/components/visibility-icon.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { GlobeAltIcon, LinkIcon, LockClosedIcon } from '@heroicons/react/24/outline';
|
||||
import { Visibility } from '@/types/privacy-visibility';
|
||||
import { Tooltip } from '@nextui-org/react';
|
||||
|
||||
export const VisibilityIcon = ({ visibility, className }: { visibility: Visibility, className?: string; }) => {
|
||||
if (visibility === Visibility.PUBLIC)
|
||||
return (<Tooltip content="Public">
|
||||
<GlobeAltIcon className={className} />
|
||||
</Tooltip>);
|
||||
|
||||
if (visibility === Visibility.UNLISTED)
|
||||
return (<Tooltip content="Unlisted">
|
||||
<LinkIcon className={className} />
|
||||
</Tooltip>);
|
||||
|
||||
return (<Tooltip content="Private">
|
||||
<LockClosedIcon className={className} />
|
||||
</Tooltip>);
|
||||
};
|
148
src/data/team.ts
Normal file
148
src/data/team.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { GeneratedDB, db } from '@/db';
|
||||
import { JoinPrivacy, Visibility } from '@/types/privacy-visibility';
|
||||
import crypto from 'crypto';
|
||||
import { userIsVisible, withUsersVisibleTo } from './user';
|
||||
import { Transaction } from 'kysely';
|
||||
import { UserPayload } from '@/types/user';
|
||||
import { hasPermission } from '@/helpers/permissions';
|
||||
import { UserPermissions } from '@/types/permissions';
|
||||
|
||||
const createActaeonTeamsFromExistingTeams = async () => {
|
||||
await db.transaction().execute(async trx => {
|
||||
const chuniTeams = (await trx.selectFrom('chuni_profile_team as chuni')
|
||||
.leftJoin('actaeon_teams as teams', 'teams.chuniTeam', 'chuni.id')
|
||||
.where('teams.chuniTeam', 'is', null)
|
||||
.select(({ selectFrom }) => [
|
||||
'chuni.id', 'chuni.teamName',
|
||||
selectFrom('chuni_profile_data as profile')
|
||||
.whereRef('profile.teamId', '=', 'chuni.id')
|
||||
.select('profile.user')
|
||||
.limit(1)
|
||||
.as('owner')
|
||||
] as const)
|
||||
.execute())
|
||||
.filter(x => x.owner !== null);
|
||||
|
||||
if (!chuniTeams.length) return;
|
||||
|
||||
const insertValues = chuniTeams.map(team => ({
|
||||
uuid: crypto.randomUUID(),
|
||||
visibility: Visibility.PRIVATE,
|
||||
joinPrivacy: JoinPrivacy.INVITE_ONLY,
|
||||
name: team.teamName,
|
||||
owner: team.owner!,
|
||||
chuniTeam: team.id!
|
||||
}));
|
||||
|
||||
await trx.insertInto('actaeon_teams')
|
||||
.values(insertValues)
|
||||
.executeTakeFirst();
|
||||
|
||||
for (const val of insertValues) {
|
||||
await trx.updateTable('actaeon_user_ext')
|
||||
.where('userId', '=', val.owner)
|
||||
.set({ team: val.uuid })
|
||||
.executeTakeFirst();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getTeams = async ({ showUnlisted, uuids, user }:
|
||||
{ showUnlisted?: boolean, uuids?: string[], user?: UserPayload | null; }) => {
|
||||
await createActaeonTeamsFromExistingTeams().catch(console.error);
|
||||
|
||||
const res = await withUsersVisibleTo(user)
|
||||
.selectFrom('actaeon_teams as team')
|
||||
.leftJoin('chuni_profile_team as chuni', 'team.chuniTeam', 'chuni.id')
|
||||
.leftJoin('actaeon_user_ext as ext', 'ext.userId', 'team.owner')
|
||||
.leftJoin('aime_user as owner', 'owner.id', 'team.owner')
|
||||
.select(({ selectFrom, eb }) => [
|
||||
'team.uuid', 'team.visibility', 'team.joinPrivacy', 'team.name',
|
||||
'owner.username as ownerUsername', 'ext.uuid as ownerUuid',
|
||||
userIsVisible('owner.id').as('ownerVisible'),
|
||||
selectFrom('actaeon_user_ext as uext2')
|
||||
.whereRef('uext2.team', '=', 'team.uuid')
|
||||
.select(({ fn }) => fn.count('uext2.uuid').as('count'))
|
||||
.as('userCount'),
|
||||
eb('team.uuid', '=', user?.team!).as('isMember'),
|
||||
eb.or([
|
||||
eb('team.visibility', '=', Visibility.PUBLIC),
|
||||
eb('team.uuid', '=', user?.team!),
|
||||
...(showUnlisted ? [
|
||||
eb('team.visibility', '=', Visibility.UNLISTED)
|
||||
] : []),
|
||||
]).as('visible'),
|
||||
'chuni.teamPoint as chuniTeamPoint'
|
||||
] as const)
|
||||
.where(({ and, eb }) => and([
|
||||
...(uuids?.length ? [
|
||||
eb('team.uuid', 'in', uuids)
|
||||
] : [eb.lit(true)])
|
||||
]))
|
||||
.execute();
|
||||
|
||||
const userTeam = res.findIndex(t => t.uuid === user?.team);
|
||||
if (userTeam !== -1)
|
||||
res.unshift(...res.splice(userTeam, 1));
|
||||
|
||||
return res.map(({ ownerUsername, ownerUuid, ownerVisible, ...rest }) => ({
|
||||
...rest,
|
||||
ownerUsername: ownerVisible ? ownerUsername : null,
|
||||
ownerUuid: ownerVisible ? ownerUuid : null
|
||||
}));
|
||||
};
|
||||
|
||||
export type Team = Awaited<ReturnType<typeof getTeams>>[number];
|
||||
|
||||
export const syncUserTeams = async (user: number, team?: { chuniTeam: number } | null, transaction?: Transaction<GeneratedDB>) => {
|
||||
const cb = async (trx: Transaction<GeneratedDB>) => {
|
||||
if (team === undefined)
|
||||
team = await db.selectFrom('actaeon_user_ext as ext')
|
||||
.where('ext.userId', '=', user)
|
||||
.innerJoin('actaeon_teams as teams', 'teams.uuid', 'ext.team')
|
||||
.select('teams.chuniTeam')
|
||||
.executeTakeFirst();
|
||||
|
||||
await db.updateTable('chuni_profile_data')
|
||||
.where('user', '=', user)
|
||||
.set({ teamId: team?.chuniTeam ?? null })
|
||||
.executeTakeFirst();
|
||||
};
|
||||
|
||||
if (transaction)
|
||||
await cb(transaction)
|
||||
else
|
||||
await db.transaction().execute(cb);
|
||||
};
|
||||
|
||||
export const getTeamUsers = async ({ user, team }: { user?: UserPayload | null, team: Team }) => {
|
||||
const res = await withUsersVisibleTo(user, { allTeam: !!team.ownerUuid && team.ownerUuid === user?.uuid })
|
||||
.selectFrom('actaeon_user_ext as ext')
|
||||
.leftJoin('aime_user as u', 'u.id', 'ext.userId')
|
||||
.where('ext.team', '=', team.uuid)
|
||||
.select(['u.username', 'ext.uuid', userIsVisible('u.id').as('visible'), 'u.id'])
|
||||
.execute();
|
||||
|
||||
const data = res.map(({ username, uuid, visible, id }) => ({
|
||||
uuid: visible ? uuid : null,
|
||||
username: visible ? username : null,
|
||||
visible,
|
||||
isOwner: uuid === team.ownerUuid,
|
||||
id: visible ? id : null
|
||||
}));
|
||||
|
||||
data.unshift(...data.splice(data.findIndex(u => u.isOwner), 1));
|
||||
return data;
|
||||
};
|
||||
|
||||
export type TeamUser = Awaited<ReturnType<typeof getTeamUsers>>[number];
|
||||
|
||||
export const getTeamInviteLinks = async ({ user, team }: { user?: UserPayload | null, team: Team }) => {
|
||||
if (!hasPermission(user?.permissions, UserPermissions.OWNER) && user?.uuid !== team.ownerUuid)
|
||||
return [];
|
||||
|
||||
return db.selectFrom('actaeon_team_join_keys')
|
||||
.where('teamId', '=', team.uuid)
|
||||
.selectAll()
|
||||
.execute();
|
||||
};
|
@ -1,10 +1,16 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
if (['true', 'yes', '1'].includes(process.env.AUTOMIGRATE?.toLowerCase()!)) {
|
||||
const url = new URL(process.env.DATABASE_URL!);
|
||||
url.searchParams.set('multipleStatements', 'true');
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
// using require here increases build times to like 10 minutes for some reason
|
||||
const DBMigrate = await eval('imp' + 'ort("db-migrate")');
|
||||
const dbmigrate = DBMigrate.getInstance(true);
|
||||
await dbmigrate.up();
|
||||
}
|
||||
} else if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
(globalThis as any).bcrypt = {};
|
||||
(globalThis as any).mysql2 = {};
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,9 @@ export const MAIN_ROUTES: Route = {
|
||||
routes: [{
|
||||
url: '/dashboard',
|
||||
name: 'Overview'
|
||||
}, {
|
||||
url: '/team',
|
||||
name: 'Teams'
|
||||
}, {
|
||||
url: '/arcade',
|
||||
name: 'Arcades'
|
||||
|
Loading…
Reference in New Issue
Block a user