add teams editing

This commit is contained in:
sk1982 2024-03-30 06:29:55 -04:00
parent 35b7e0bb60
commit f26ff35643
22 changed files with 905 additions and 66 deletions

14
db-migrate.cjs Normal file
View 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]);

View 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
};

View File

@ -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;

View File

@ -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;

View File

@ -34,7 +34,7 @@ const nextConfig = {
},
productionBrowserSourceMaps: true,
webpack: config => {
config.externals = [...config.externals, 'bcrypt'];
config.externals = [...config.externals, 'bcrypt', 'mysql2'];
return config;
}
};

View File

@ -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
View 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;
};

View File

@ -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}

View 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}`} />)
}

View 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} />)
}

View 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:&nbsp;
{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>);
}

View File

@ -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" />

View File

@ -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

View 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>);
};

View File

@ -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">

View File

@ -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
View 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>);
};

View 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>);
};

View 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
View 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();
};

View File

@ -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 = {};
}
}

View File

@ -28,6 +28,9 @@ export const MAIN_ROUTES: Route = {
routes: [{
url: '/dashboard',
name: 'Overview'
}, {
url: '/team',
name: 'Teams'
}, {
url: '/arcade',
name: 'Arcades'