ui: iam & settings update

This commit is contained in:
Andras Bacsai 2022-10-03 11:31:50 +02:00
parent 9a67cf7355
commit ed02c1ae36
18 changed files with 597 additions and 574 deletions

View File

@ -5,9 +5,10 @@ import { decrypt, errorHandler, prisma, uniqueName } from '../../../../lib/commo
import { day } from '../../../../lib/dayjs';
import type { OnlyId } from '../../../../types';
import type { BodyId, InviteToTeam, SaveTeam, SetPermission } from './types';
import type { BodyId, DeleteUserFromTeam, InviteToTeam, SaveTeam, SetPermission } from './types';
export async function listTeams(request: FastifyRequest) {
export async function listAccounts(request: FastifyRequest) {
try {
const userId = request.user.userId;
const teamId = request.user.teamId;
@ -15,10 +16,24 @@ export async function listTeams(request: FastifyRequest) {
where: { id: userId },
select: { id: true, email: true, teams: true }
});
let accounts = [];
let allTeams = [];
let accounts = await prisma.user.findMany({ where: { teams: { some: { id: teamId } } }, select: { id: true, email: true, teams: true } });
if (teamId === '0') {
accounts = await prisma.user.findMany({ select: { id: true, email: true, teams: true } });
}
return {
account,
accounts
};
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
export async function listTeams(request: FastifyRequest) {
try {
const userId = request.user.userId;
const teamId = request.user.teamId;
let allTeams = [];
if (teamId === '0') {
allTeams = await prisma.team.findMany({
where: { users: { none: { id: userId } } },
include: { permissions: true }
@ -28,18 +43,30 @@ export async function listTeams(request: FastifyRequest) {
where: { users: { some: { id: userId } } },
include: { permissions: true }
});
const invitations = await prisma.teamInvitation.findMany({ where: { uid: userId } });
return {
ownTeams,
allTeams,
invitations,
account,
accounts
};
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
export async function removeUserFromTeam(request: FastifyRequest<DeleteUserFromTeam>, reply: FastifyReply) {
try {
const { uid } = request.body;
const { id } = request.params;
const userId = request.user.userId;
const foundUser = await prisma.team.findMany({ where: { id, users: { some: { id: userId } } } });
if (foundUser.length === 0) {
return errorHandler({ status: 404, message: 'Team not found' });
}
await prisma.team.update({ where: { id }, data: { users: { disconnect: { id: uid } } } });
await prisma.permission.deleteMany({ where: { teamId: id, userId: uid } })
return reply.code(201).send()
} catch ({ status, message }) {
return errorHandler({ status, message })
}
}
export async function deleteTeam(request: FastifyRequest<OnlyId>, reply: FastifyReply) {
try {
const userId = request.user.userId;

View File

@ -1,19 +1,22 @@
import { FastifyPluginAsync } from 'fastify';
import { acceptInvitation, changePassword, deleteTeam, getTeam, inviteToTeam, listTeams, newTeam, removeUser, revokeInvitation, saveTeam, setPermission } from './handlers';
import { acceptInvitation, changePassword, deleteTeam, getTeam, inviteToTeam, listAccounts, listTeams, newTeam, removeUser, removeUserFromTeam, revokeInvitation, saveTeam, setPermission } from './handlers';
import type { OnlyId } from '../../../../types';
import type { BodyId, InviteToTeam, SaveTeam, SetPermission } from './types';
import type { BodyId, DeleteUserFromTeam, InviteToTeam, SaveTeam, SetPermission } from './types';
const root: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.addHook('onRequest', async (request) => {
return await request.jwtVerify()
})
fastify.get('/', async (request) => await listTeams(request));
fastify.get('/', async (request) => await listAccounts(request));
fastify.post('/new', async (request, reply) => await newTeam(request, reply));
fastify.get('/teams', async (request) => await listTeams(request));
fastify.get<OnlyId>('/team/:id', async (request, reply) => await getTeam(request, reply));
fastify.post<SaveTeam>('/team/:id', async (request, reply) => await saveTeam(request, reply));
fastify.delete<OnlyId>('/team/:id', async (request, reply) => await deleteTeam(request, reply));
fastify.post<DeleteUserFromTeam>('/team/:id/user/remove', async (request, reply) => await removeUserFromTeam(request, reply));
fastify.post<InviteToTeam>('/team/:id/invitation/invite', async (request, reply) => await inviteToTeam(request, reply))
fastify.post<BodyId>('/team/:id/invitation/accept', async (request) => await acceptInvitation(request));

View File

@ -5,6 +5,14 @@ export interface SaveTeam extends OnlyId {
name: string
}
}
export interface DeleteUserFromTeam {
Body: {
uid: string
},
Params: {
id: string
}
}
export interface InviteToTeam {
Body: {
email: string,

View File

@ -328,7 +328,7 @@
"members": "Members",
"root_team_explainer": "This is the <span class='text-red-500 '>root</span> team. That means members of this group can manage instance wide settings and have all the priviliges in Coolify (imagine like root user on Linux).",
"permission": "Permission",
"you": "(You)",
"you": "You",
"promote_to": "Promote to {{grade}}",
"revoke_invitation": "Revoke invitation",
"pending_invitation": "Pending invitation",

View File

@ -318,6 +318,6 @@
"root": "(suprême)",
"root_team_explainer": "Il s'agit de l'équipe <span class='text-red-500 font-bold'>suprême</span>. \nCela signifie que les membres de ce groupe peuvent gérer les paramètres à l'échelle de l'instance et avoir tous les privilèges dans Coolify (imaginez comme un utilisateur root sous Linux).",
"send_invitation": "Envoyer une invitation",
"you": "(Toi)"
"you": "Toi"
}
}

View File

@ -352,6 +352,7 @@
<li>
<a
id="servers"
class="no-underline icons hover:text-white hover:bg-sky-500"
sveltekit:prefetch
href="/servers"

View File

@ -0,0 +1,64 @@
<script lang="ts">
export let account: any;
export let accounts: any = [];
import { del, get, post } from '$lib/api';
import { errorNotification } from '$lib/common';
import { addToast, appSession } from '$lib/store';
async function resetPassword(id: any) {
const sure = window.confirm('Are you sure you want to reset the password?');
if (!sure) {
return;
}
try {
await post(`/iam/user/password`, { id });
return addToast({
message: 'Password reset successfully. Please relogin to reset it.',
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function deleteAccount(id: any) {
if (id === $appSession.userId || account.id === '0') return;
const sure = window.confirm('Are you sure you want to delete this user?');
if (!sure) {
return;
}
try {
await del(`/iam/user/remove`, { id });
addToast({
message: 'Account deleted.',
type: 'success'
});
const data = await get('/iam');
accounts = data.accounts;
} catch (error) {
return errorNotification(error);
}
}
</script>
<div class="flex flex-col lg:flex-row lg:space-y-0 space-y-2 lg:space-x-4">
<input
disabled
class="input w-full text-white"
readonly
placeholder="email"
value={account.email}
/>
<div class="flex flex-row items-center justify-center space-x-2 w-full lg:w-96">
<div class="flex items-center justify-center">
<button class="btn btn-sm btn-primary" on:click={() => resetPassword(account.id)}
>Reset Password</button
>
</div>
<div class="flex justify-center">
<button
class="btn btn-sm btn-error"
disabled={account.id === $appSession.userId || account.id === '0'}
on:click={() => deleteAccount(account.id)}>Delete Account</button
>
</div>
</div>
</div>

View File

@ -4,29 +4,32 @@
</script>
<ul class="menu border bg-coolgray-100 border-coolgray-200 rounded p-2 space-y-2 sticky top-4">
{#if $appSession.pendingInvitations.length > 0}
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/iam/pending`}>
<a href={`/iam/pending`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
{#if $appSession.pendingInvitations.length > 0}
<li class="menu-title">
<span>IAM</span>
</li>
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/iam/pending`}>
<a href={`/iam/pending`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 21v-6.5a3.5 3.5 0 0 0 -7 0v6.5h18v-6a4 4 0 0 0 -4 -4h-10.5" />
<path d="M12 11v-8h4l2 2l-2 2h-4" />
<path d="M6 15h1" />
</svg>Pending Invitations</a
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5"
/>
</svg>Pending Invitations</a
>
</li>
{/if}
</li>
{/if}
<li class="menu-title">
<span>Configuration</span>
<span>IAM</span>
</li>
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/iam`}>
@ -42,14 +45,13 @@
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5"
/>
</svg>Accounts</a
<circle cx="12" cy="7" r="4" />
<path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
</svg>{$appSession.userId === '0' && $appSession.teamId === '0' ? 'Accounts' : 'Account'}</a
>
</li>
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/iam/teams`}>
<a href={`/iam/teams`} class="no-under line w-full"
<li class="rounded" class:bg-coollabs={$page.url.pathname.startsWith(`/iam/teams`)}>
<a href={`/iam/teams`} class="no-underline w-full"
><svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
@ -61,9 +63,10 @@
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5"
/>
<circle cx="9" cy="7" r="4" />
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
<path d="M21 21v-2a4 4 0 0 0 -3 -3.85" />
</svg>Teams</a
>
</li>

View File

@ -1,21 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { appSession } from '$lib/store';
import Menu from './_Menu.svelte';
</script>
<div class="mx-auto max-w-screen-2xl px-6 grid grid-cols-1 lg:grid-cols-2">
<nav class="header flex flex-row order-2 lg:order-1 px-0 lg:px-4 items-start">
<div class="title lg:pb-10">
{#if $page.url.pathname === `/iam`}
Identity & Access Management
{:else}
<div class="flex justify-center items-center space-x-2">
<div>Configurations</div>
</div>
{/if}
</div>
<div class="title lg:pb-10">Identity & Access Management</div>
</nav>
</div>
<div class="mx-auto max-w-screen-2xl px-0 lg:px-2 grid grid-cols-1 lg:grid-cols-4">

View File

@ -20,76 +20,15 @@
<script lang="ts">
export let account: any;
export let accounts: any;
export let invitations: any;
export let ownTeams: any;
export let allTeams: any;
import { del, get, post } from '$lib/api';
import { errorNotification } from '$lib/common';
import { addToast, appSession } from '$lib/store';
import { appSession } from '$lib/store';
import { get, post } from '$lib/api';
import { goto } from '$app/navigation';
import Cookies from 'js-cookie';
import { page } from '$app/stores';
import Account from './_Account.svelte';
let search = '';
let searchResults: any = [];
// if (accounts.length === 0) {
// accounts.push(account);
// }
async function resetPassword(id: any) {
const sure = window.confirm('Are you sure you want to reset the password?');
if (!sure) {
return;
}
try {
await post(`/iam/user/password`, { id });
return addToast({
message: 'Password reset successfully. Please relogin to reset it.',
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function deleteUser(id: any) {
const sure = window.confirm('Are you sure you want to delete this user?');
if (!sure) {
return;
}
try {
await del(`/iam/user/remove`, { id });
addToast({
message: 'Account deleted.',
type: 'success'
});
const data = await get('/iam');
accounts = data.accounts;
} catch (error) {
return errorNotification(error);
}
}
async function switchTeam(selectedTeamId: any) {
try {
const payload = await get(`/user?teamId=${selectedTeamId}`);
if (payload.token) {
Cookies.set('token', payload.token, {
path: '/'
});
$appSession.teamId = payload.teamId;
$appSession.userId = payload.userId;
$appSession.permission = payload.permission;
$appSession.isAdmin = payload.isAdmin;
return window.location.reload();
}
} catch (error) {
console.error(error);
return errorNotification(error);
}
}
async function newTeam() {
const { id } = await post('/iam/new', {});
return await goto(`/iam/team/${id}`, { replaceState: true });
}
function searchAccount() {
searchResults = accounts.filter((account: { email: string | string[] }) => {
return account.email.includes(search);
@ -100,199 +39,35 @@
<div class="w-full">
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2 items-center">
<div class="title font-bold pb-3">Accounts</div>
</div>
</div>
</div>
<div class="w-full grid gap-2">
<input
class="input w-full mb-4"
bind:value={search}
on:input={searchAccount}
placeholder="Search for account..."
/>
<div class="flex flex-col pb-2 space-y-4 lg:space-y-2">
{#if searchResults.length > 0}
{#each searchResults as account}
<div class="flex flex-col lg:flex-row lg:space-y-0 space-y-2">
<input
disabled
class="input w-full lg:w-64 text-white"
readonly
placeholder="email"
value={account.email}
/>
<div class="flex flex-row items-center justify-center space-x-2 w-full lg:w-96">
<div class="flex items-center justify-center">
<button class="btn btn-sm btn-primary">Reset Password</button>
</div>
<div class="flex justify-center">
<button class="btn btn-sm btn-error">Delete Account</button>
</div>
</div>
</div>
{/each}
{:else if searchResults.length === 0 && search !== ''}
<div>Nothing found.</div>
{:else}
{#each accounts as account}
<div class="flex flex-col lg:flex-row lg:space-y-0 space-y-2 lg:space-x-4">
<input
disabled
class="input w-full text-white"
readonly
placeholder="email"
value={account.email}
/>
<div class="flex flex-row items-center justify-center space-x-2 w-full lg:w-96">
<div class="flex items-center justify-center">
<button class="btn btn-sm btn-primary">Reset Password</button>
</div>
<div class="flex justify-center">
<button class="btn btn-sm btn-error">Delete Account</button>
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>
<!-- <nav class="header">
<h1 class="mr-4 text-2xl tracking-tight font-bold">Identity and Access Management</h1>
<button on:click={newTeam} class="btn btn-square btn-sm bg-iam">
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/></svg
>
</button>
</nav>
<br />
{#if invitations.length > 0}
<div class="mx-auto max-w-6xl px-6 py-4">
<div class="title font-bold">Pending invitations</div>
<div class="pt-10 text-center">
{#each invitations as invitation}
<div class="flex justify-center space-x-2">
<div>
Invited to <span class="font-bold text-pink-600">{invitation.teamName}</span> with
<span class="font-bold text-rose-600">{invitation.permission}</span> permission.
</div>
<button
class="btn btn-sm btn-success"
on:click={() => acceptInvitation(invitation.id, invitation.teamId)}>Accept</button
>
<button
class="btn btn-sm btn-error"
on:click={() => revokeInvitation(invitation.id, invitation.teamId)}>Delete</button
>
</div>
{/each}
</div>
</div>
{/if}
<div class="mx-auto max-w-6xl px-6 py-4">
{#if $appSession.teamId === '0' && accounts.length > 0}
<div class="title font-bold">Accounts</div>
{:else}
<div class="title font-bold">Account</div>
{/if}
<div class="flex items-center justify-center pt-10">
<table class="mx-2 text-left">
<thead class="mb-2">
<tr>
{#if accounts.length > 1}
<th class="px-2">Email</th>
<th>Actions</th>
{/if}
</tr>
</thead>
<tbody>
{#each accounts as account}
<tr class="grid items-center justify-center gap-2 lg:grid-flow-col">
<td class="px-2">{account.email}</td>
<td class="flex space-x-2">
<form on:submit|preventDefault={() => resetPassword(account.id)}>
<button class="my-4 btn btn-sm bg-iam">Reset Password</button>
</form>
<form on:submit|preventDefault={() => deleteUser(account.id)}>
<button
disabled={account.id === $appSession.userId}
class="my-4 btn btn-sm"
type="submit">Delete User</button
>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<div class="mx-auto max-w-6xl px-6">
<div class="title font-bold">Teams</div>
<div class="flex-col items-center justify-center pt-10">
<div class="flex flex-row flex-wrap justify-center px-2 pb-10 md:flex-row">
{#each ownTeams as team}
<a href="/iam/team/{team.id}" class="p-2 no-underline">
<div class="box-selection relative">
<div>
<div class="truncate text-center text-xl font-bold">
{team.name}
</div>
<div class="mt-1 text-center text-xs">
{team.permissions?.length} member(s)
</div>
</div>
<div class="flex items-center justify-center pt-3">
<button
on:click|preventDefault={() => switchTeam(team.id)}
class="btn btn-sm"
class:bg-fuchsia-600={$appSession.teamId !== team.id}
class:hover:bg-fuchsia-500={$appSession.teamId !== team.id}
class:bg-transparent={$appSession.teamId === team.id}
disabled={$appSession.teamId === team.id}
>{$appSession.teamId === team.id ? 'Current Team' : 'Switch Team'}</button
>
</div>
</div>
</a>
{/each}
</div>
{#if $appSession.teamId === '0' && allTeams.length > 0}
<div class="pb-5 pt-10 text-xl font-bold">Other Teams</div>
<div class="flex flex-row flex-wrap justify-center px-2 md:flex-row">
{#each allTeams as team}
<a href="/iam/team/{team.id}" class="p-2 no-underline">
<div
class="box-selection relative"
class:hover:bg-fuchsia-600={team.id !== '0'}
class:hover:bg-red-500={team.id === '0'}
>
<div class="truncate text-center text-xl font-bold">
{team.name}
</div>
<div class="mt-1 text-center">{team.permissions?.length} member(s)</div>
</div>
</a>
{/each}
<div class="title font-bold pb-3">
{$appSession.userId === '0' && $appSession.teamId === '0' ? 'Accounts' : 'Your account'}
</div>
{/if}
</div>
</div>
</div> -->
</div>
{#if $appSession.userId === '0' && $appSession.teamId === '0'}
<div class="w-full grid gap-2">
<input
class="input w-full mb-4"
bind:value={search}
on:input={searchAccount}
placeholder="Search for account..."
/>
<div class="flex flex-col pb-2 space-y-4 lg:space-y-2">
{#if searchResults.length > 0}
{#each searchResults as account}
<Account {account} {accounts} />
{/each}
{:else if searchResults.length === 0 && search !== ''}
<div>Nothing found.</div>
{:else}
{#each accounts as account}
<Account {account} {accounts} />
{/each}
{/if}
</div>
</div>
{:else}
<Account {account} />
{/if}

View File

@ -1,7 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { post } from '$lib/api';
import { errorNotification } from '$lib/common';
import { appSession } from '$lib/store';
if ($appSession.pendingInvitations.length === 0) {
goto('/iam/teams');
}
async function acceptInvitation(id: any, teamId: any) {
try {
await post(`/iam/team/${teamId}/invitation/accept`, { id });

View File

@ -1,84 +0,0 @@
<script context="module" lang="ts">
import { del, get } from '$lib/api';
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ params, url }) => {
try {
const response = await get(`/iam/team/${params.id}`);
if (!response.permissions || Object.entries(response.permissions).length === 0) {
return {
status: 302,
redirect: '/iam'
};
}
return {
props: {
...response
},
stuff: {
...response
}
};
} catch (error) {
return handlerNotFoundLoad(error, url);
}
};
</script>
<script lang="ts">
export let team: any;
export let currentTeam: string;
export let teams: any;
import { page } from '$app/stores';
import { errorNotification, handlerNotFoundLoad } from '$lib/common';
import { appSession } from '$lib/store';
import { t } from '$lib/translations';
import DeleteIcon from '$lib/components/DeleteIcon.svelte';
import { goto } from '$app/navigation';
import Cookies from 'js-cookie';
import Tooltip from '$lib/components/Tooltip.svelte';
const { id } = $page.params;
async function deleteTeam() {
const sure = confirm('Are you sure you want to delete this team?');
if (sure) {
try {
await del(`/iam/team/${id}`, { id });
if (currentTeam === id) {
const switchTeam = teams.find((team: any) => team.id !== id);
const payload = await get(`/user?teamId=${switchTeam.id}`);
if (payload.token) {
Cookies.set('token', payload.token, {
path: '/'
});
$appSession.teamId = payload.teamId;
$appSession.userId = payload.userId;
$appSession.permission = payload.permission;
$appSession.isAdmin = payload.isAdmin;
return window.location.assign('/iam');
}
}
return await goto('/iam', { replaceState: true });
} catch (error) {
return errorNotification(error);
}
}
}
</script>
{#if id !== 'new'}
<nav class="nav-side">
{#if team.id !== '0'}
<button
id="delete"
on:click={deleteTeam}
type="submit"
disabled={!$appSession.isAdmin}
class:hover:text-red-500={$appSession.isAdmin}
class="icons bg-transparent text-sm"><DeleteIcon /></button
>
<Tooltip triggeredBy="#delete">Delete</Tooltip>
{/if}
</nav>
{/if}
<slot />

View File

@ -0,0 +1,108 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = async () => {
try {
const response = await get(`/iam/teams`);
return {
props: {
...response
}
};
} catch (error: any) {
return {
status: 500,
error: new Error(error)
};
}
};
</script>
<script lang="ts">
export let allTeams: any;
export let ownTeams: any;
import { get, post } from '$lib/api';
import Cookies from 'js-cookie';
import { appSession } from '$lib/store';
import { errorNotification } from '$lib/common';
import { goto } from '$app/navigation';
async function switchTeam(selectedTeamId: any) {
try {
const payload = await get(`/user?teamId=${selectedTeamId}`);
if (payload.token) {
Cookies.set('token', payload.token, {
path: '/'
});
$appSession.teamId = payload.teamId;
$appSession.userId = payload.userId;
$appSession.permission = payload.permission;
$appSession.isAdmin = payload.isAdmin;
return window.location.reload();
}
} catch (error) {
console.error(error);
return errorNotification(error);
}
}
async function newTeam() {
const { id } = await post('/iam/new', {});
return await goto(`/iam/teams/${id}`, { replaceState: true });
}
</script>
<div class="w-full">
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2 items-center pb-3">
<div class="title font-bold">Teams</div>
<button on:click={newTeam} class="btn btn-sm btn-primary"> Add New Team </button>
</div>
</div>
</div>
<div class="grid grid-col gap-4 auto-cols-max grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6">
{#each ownTeams as team}
<a href="/iam/teams/{team.id}" class="p-2 no-underline">
<div
class="flex flex-col w-full rounded p-5 bg-coolgray-200 hover:bg-coolgray-300 indicator duration-150 h-36"
>
<div>
<div class="truncate text-center text-xl font-bold">
{team.name}
{#if $appSession.teamId === team.id}
<button class="badge bg-applications text-white font-bold rounded">Active Team</button
>
{/if}
</div>
<div class="mt-1 text-center text-xs">
{team.permissions?.length} member(s)
</div>
</div>
<div class="flex items-center justify-center pt-3">
{#if $appSession.teamId !== team.id}
<button
on:click|preventDefault={() => switchTeam(team.id)}
class="btn btn-sm btn-primary">Switch to this team</button
>
{/if}
</div>
</div>
</a>
{/each}
</div>
<div class="divider w-32 mx-auto" />
<div class="grid grid-col gap-4 auto-cols-max grid-cols-1 md:grid-cols-2 lg:grid-cols-3 px-6">
{#if $appSession.teamId === '0' && allTeams.length > 0}
{#each allTeams as team}
<a href="/iam/teams/{team.id}" class="p-2 no-underline">
<div
class="flex flex-col w-full rounded p-5 bg-coolgray-200 hover:bg-coolgray-300 indicator duration-150 relative"
>
<div class="truncate text-center text-xl font-bold">
{team.name}
</div>
<div class="mt-1 text-center text-xs">{team.permissions?.length} member(s)</div>
</div>
</a>
{/each}
{/if}
</div>

View File

@ -0,0 +1,31 @@
<script context="module" lang="ts">
import { del, get } from '$lib/api';
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ params, url }) => {
try {
const response = await get(`/iam/team/${params.id}`);
if (!response.permissions || Object.entries(response.permissions).length === 0) {
return {
status: 302,
redirect: '/iam/teams'
};
}
return {
props: {
...response
},
stuff: {
...response
}
};
} catch (error) {
return handlerNotFoundLoad(error, url);
}
};
</script>
<script lang="ts">
import { handlerNotFoundLoad } from '$lib/common';
</script>
<slot />

View File

@ -8,16 +8,23 @@
</script>
<script lang="ts">
export let currentTeam: string;
export let teams: any[];
export let permissions: any;
export let team: any;
export let invitations: any[];
import { page } from '$app/stores';
import SimpleExplainer from '$lib/components/SimpleExplainer.svelte';
import { post } from '$lib/api';
import { del, get, post } from '$lib/api';
import { t } from '$lib/translations';
import { errorNotification } from '$lib/common';
import { appSession } from '$lib/store';
import { addToast, appSession } from '$lib/store';
import Explainer from '$lib/components/Explainer.svelte';
import Cookies from 'js-cookie';
import { goto } from '$app/navigation';
const { id } = $page.params;
let invitation: any = {
teamName: team.name,
email: null,
@ -54,7 +61,7 @@
}
async function removeFromTeam(uid: string) {
try {
await post(`/iam/team/${id}/user/remove`, { teamId: team.id, uid });
await post(`/iam/team/${id}/user/remove`, { uid });
return window.location.reload();
} catch (error) {
return errorNotification(error);
@ -75,41 +82,124 @@
async function handleSubmit() {
try {
await post(`/iam/team/${id}`, { ...team });
return window.location.reload();
return addToast({
message: 'Settings updated.',
type: 'success'
});
} catch (error) {
return errorNotification(error);
}
}
async function deleteTeam() {
const sure = confirm('Are you sure you want to delete this team?');
if (sure) {
try {
const switchTeam = teams.find((team: any) => team.id !== id);
if (!switchTeam) {
return addToast({
message: 'You cannot delete your last team.',
type: 'error'
});
}
await del(`/iam/team/${id}`, { id });
if (currentTeam === id) {
const payload = await get(`/user?teamId=${switchTeam.id}`);
if (payload.token) {
Cookies.set('token', payload.token, {
path: '/'
});
$appSession.teamId = payload.teamId;
$appSession.userId = payload.userId;
$appSession.permission = payload.permission;
$appSession.isAdmin = payload.isAdmin;
return window.location.assign('/iam');
}
}
return await goto('/iam/teams', { replaceState: true });
} catch (error) {
return errorNotification(error);
}
}
}
async function leaveTeam(uid: string) {
const sure = confirm('Are you sure you want to leave this team?');
if (sure) {
try {
const switchTeam = teams.find((team: any) => team.id !== id);
const foundAdmin = team.permissions.filter(
(permission: any) => permission.userId !== uid && permission.permission === 'admin'
);
if (!switchTeam) {
return addToast({
message: 'You cannot leave your last team.',
type: 'error'
});
}
if (!foundAdmin.length) {
return addToast({
message: 'You cannot leave this team without an admin.',
type: 'error'
});
}
await post(`/iam/team/${id}/user/remove`, { uid });
if (currentTeam === id) {
const payload = await get(`/user?teamId=${switchTeam.id}`);
if (payload.token) {
Cookies.set('token', payload.token, {
path: '/'
});
$appSession.teamId = payload.teamId;
$appSession.userId = payload.userId;
$appSession.permission = payload.permission;
$appSession.isAdmin = payload.isAdmin;
return window.location.assign('/iam');
}
}
return await goto('/iam/teams', { replaceState: true });
} catch (error) {
return errorNotification(error);
}
}
}
</script>
<div class="flex space-x-1 p-6 px-6 text-2xl font-bold">
<div class="tracking-tight">{$t('index.team')}</div>
<span class="arrow-right-applications px-1 text-fuchsia-500">></span>
<span class="pr-2">{team.name}</span>
<div class="w-full">
<div class="mx-auto w-full">
<div class="flex flex-row border-b border-coolgray-500 mb-6 space-x-2 items-center pb-3">
<div class="title font-bold">{team.name}</div>
<button class="btn btn-sm bg-primary" on:click={handleSubmit}>{$t('forms.save')}</button>
<button
id="delete"
on:click={deleteTeam}
type="submit"
disabled={!$appSession.isAdmin}
class="btn btn-sm bg-error">Remove Team</button
>
</div>
</div>
</div>
<div class="mx-auto max-w-6xl px-6">
<form on:submit|preventDefault={handleSubmit} class=" py-4">
<div class="flex space-x-1 pb-5">
<div class="title font-bold">{$t('index.settings')}</div>
<button class="btn btn-sm bg-iam" type="submit">{$t('forms.save')}</button>
</div>
<div class="grid grid-flow-row gap-2 px-10">
<div class="mt-2 grid grid-cols-2">
<div class="flex-col">
<label for="name" class="text-base font-bold text-stone-100">{$t('forms.name')}</label>
{#if team.id === '0'}
<SimpleExplainer customClass="w-full" text={$t('team.root_team_explainer')} />
{/if}
</div>
<input id="name" name="name" placeholder="name" bind:value={team.name} />
<div class="mx-auto">
<div class="flex space-x-1 pb-5">
<div class="title font-bold">{$t('index.settings')}</div>
</div>
<div class="grid grid-flow-row gap-2 px-4">
<div class="mt-2 grid grid-cols-2">
<div class="flex-col">
<label for="name">{$t('forms.name')}</label>
{#if team.id === '0'}
<Explainer explanation={$t('team.root_team_explainer')} />
{/if}
</div>
<input id="name" name="name" placeholder="name" bind:value={team.name} class="input w-full" />
</div>
</form>
</div>
<div class="flex space-x-1 py-5 pt-10 font-bold">
<div class="title">{$t('team.members')}</div>
</div>
<div class="px-4 sm:px-6">
<div class="px-4">
<table class="w-full border-separate text-left">
<thead>
<tr class="h-8 border-b border-coolgray-400">
@ -122,25 +212,33 @@
<tr class="text-xs">
<td class="py-4"
>{permission.user.email}
<span class="font-bold"
>{permission.user.id === $appSession.userId ? $t('team.you') : ''}</span
></td
>
{#if permission.user.id === $appSession.userId}
<span class="font-bold badge badge-primary text-xs">{$t('team.you')}</span>
{/if}
</td>
<td class="py-4">{permission.permission}</td>
{#if $appSession.isAdmin && permission.user.id !== $appSession.userId && permission.permission !== 'owner'}
<td class="flex flex-col items-center justify-center space-y-2 py-4 text-center">
<button
class="btn btn-sm btn-error"
on:click={() => removeFromTeam(permission.user.id)}>{$t('forms.remove')}</button
>
<td
class="flex flex-col lg:flex-row justify-center lg:space-y-0 space-y-2 space-x-0 lg:space-x-2 text-center"
>
<button
class="btn btn-sm"
on:click={() =>
changePermission(permission.user.id, permission.id, permission.permission)}
>{$t('team.promote_to', {
grade: permission.permission === 'admin' ? 'read' : 'admin'
grade: permission.permission === 'admin' ? 'Read' : 'Admin'
})}</button
>
<button
class="btn btn-sm btn-error"
on:click={() => removeFromTeam(permission.user.id)}>{$t('forms.remove')}</button
>
</td>
{:else if permission.user.id === $appSession.userId}
<td class="py-4 flex flex-row justify-center">
<button class="btn btn-sm btn-primary" on:click={() => leaveTeam(permission.user.id)}
>Leave Team</button
>
</td>
{:else}
<td class="text-center py-4 flex-col space-y-2">
@ -156,9 +254,7 @@
<td class="py-4 font-bold text-yellow-500">{invitation.permission}</td>
{#if isAdmin(team.permissions[0].permission)}
<td class="flex-col space-y-2 py-4 text-center">
<button
class="btn btn-sm btn-error"
on:click={() => revokeInvitation(invitation.id)}
<button class="btn btn-sm btn-error" on:click={() => revokeInvitation(invitation.id)}
>{$t('team.revoke_invitation')}</button
>
</td>
@ -174,18 +270,16 @@
<div class="flex space-x-1">
<div class="flex space-x-1">
<div class="title font-bold">{$t('team.invite_new_member')}</div>
<button class="btn btn-sm bg-iam" type="submit"
>{$t('team.send_invitation')}</button
>
<button class="btn btn-sm bg-primary" type="submit">{$t('team.send_invitation')}</button>
</div>
</div>
<SimpleExplainer text={$t('team.invite_only_register_explainer')} />
<div class="flex-col space-y-2 px-4 pt-5 sm:px-6">
<div class="flex-col pt-5">
<div class="flex space-x-0">
<input
bind:value={invitation.email}
placeholder={$t('forms.email')}
class="mr-2 w-full"
class="input mr-2 w-full"
required
/>
<div class="flex-1" />

View File

@ -373,129 +373,129 @@
{/if}
</nav>
<div class="container lg:mx-auto lg:p-0 px-8 pt-5">
<div class="space-x-2 lg:flex lg:justify-center text-center mb-4 ">
<button
class="btn btn-sm btn-ghost"
class:bg-applications={$search === '!app'}
class:hover:bg-coollabs={$search !== '!app'}
on:click={() => doSearch('!app')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block "
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentcolor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<rect x="14" y="14" width="6" height="6" rx="1" />
<line x1="14" y1="7" x2="20" y2="7" />
<line x1="17" y1="4" x2="17" y2="10" />
</svg> Applications</button
>
<button
class="btn btn-sm btn-ghost"
class:bg-services={$search === '!service'}
class:hover:bg-coollabs={$search !== '!service'}
on:click={() => doSearch('!service')}
><svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 18a4.6 4.4 0 0 1 0 -9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-12" />
</svg> Services</button
>
<button
class="btn btn-sm btn-ghost "
class:bg-databases={$search === '!db'}
class:hover:bg-coollabs={$search !== '!db'}
on:click={() => doSearch('!db')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</svg> Databases</button
>
<button
class="btn btn-sm btn-ghost"
class:bg-sources={$search === '!git'}
class:hover:bg-coollabs={$search !== '!git'}
on:click={() => doSearch('!git')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<path d="M11 6h5a2 2 0 0 1 2 2v8" />
<polyline points="14 9 11 6 14 3" />
<path d="M13 18h-5a2 2 0 0 1 -2 -2v-8" />
<polyline points="10 15 13 18 10 21" />
</svg> Git Sources</button
>
<button
class="btn btn-sm btn-ghost"
class:bg-destinations={$search === '!destination'}
class:hover:bg-coollabs={$search !== '!destination'}
on:click={() => doSearch('!destination')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1.002 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z"
/>
<path d="M5 10h3v3h-3z" />
<path d="M8 10h3v3h-3z" />
<path d="M11 10h3v3h-3z" />
<path d="M8 7h3v3h-3z" />
<path d="M11 7h3v3h-3z" />
<path d="M11 4h3v3h-3z" />
<path d="M4.571 18c1.5 0 2.047 -.074 2.958 -.78" />
<line x1="10" y1="16" x2="10" y2="16.01" />
</svg>Destinations</button
>
</div>
{#if applications.length !== 0 || destinations.length !== 0 || databases.length !== 0 || services.length !== 0 || gitSources.length !== 0 || destinations.length !== 0}
<div class="space-x-2 lg:flex lg:justify-center text-center mb-4 ">
<button
class="btn btn-sm btn-ghost"
class:bg-applications={$search === '!app'}
class:hover:bg-coollabs={$search !== '!app'}
on:click={() => doSearch('!app')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block "
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentcolor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<rect x="14" y="14" width="6" height="6" rx="1" />
<line x1="14" y1="7" x2="20" y2="7" />
<line x1="17" y1="4" x2="17" y2="10" />
</svg> Applications</button
>
<button
class="btn btn-sm btn-ghost"
class:bg-services={$search === '!service'}
class:hover:bg-coollabs={$search !== '!service'}
on:click={() => doSearch('!service')}
><svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 18a4.6 4.4 0 0 1 0 -9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-12" />
</svg> Services</button
>
<button
class="btn btn-sm btn-ghost "
class:bg-databases={$search === '!db'}
class:hover:bg-coollabs={$search !== '!db'}
on:click={() => doSearch('!db')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</svg> Databases</button
>
<button
class="btn btn-sm btn-ghost"
class:bg-sources={$search === '!git'}
class:hover:bg-coollabs={$search !== '!git'}
on:click={() => doSearch('!git')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<path d="M11 6h5a2 2 0 0 1 2 2v8" />
<polyline points="14 9 11 6 14 3" />
<path d="M13 18h-5a2 2 0 0 1 -2 -2v-8" />
<polyline points="10 15 13 18 10 21" />
</svg> Git Sources</button
>
<button
class="btn btn-sm btn-ghost"
class:bg-destinations={$search === '!destination'}
class:hover:bg-coollabs={$search !== '!destination'}
on:click={() => doSearch('!destination')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mr-2 hidden lg:block"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1.002 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z"
/>
<path d="M5 10h3v3h-3z" />
<path d="M8 10h3v3h-3z" />
<path d="M11 10h3v3h-3z" />
<path d="M8 7h3v3h-3z" />
<path d="M11 7h3v3h-3z" />
<path d="M11 4h3v3h-3z" />
<path d="M4.571 18c1.5 0 2.047 -.074 2.958 -.78" />
<line x1="10" y1="16" x2="10" y2="16.01" />
</svg>Destinations</button
>
</div>
<div class="form-control">
<div class="input-group flex w-full">
<div

View File

@ -4,10 +4,10 @@
</script>
<ul class="menu border bg-coolgray-100 border-coolgray-200 rounded-box p-2 space-y-2">
<li class="menu-title">
<span>General</span>
</li>
{#if $appSession.teamId === '0'}
<li class="menu-title">
<span>General</span>
</li>
<li class="rounded" class:bg-coollabs={$page.url.pathname === `/settings/coolify`}>
<a href={`/settings/coolify`} class="no-underline w-full">Coolify Settings</a>
</li>

View File

@ -58,19 +58,16 @@
</script>
<div class="w-full">
{#if sshKeys.length === 0}
<div class="text-sm">No SSH keys found</div>
<label for="my-modal" class="btn btn-primary mt-6" on:click={() => (isModalActive = true)}
<div class="flex border-b border-coolgray-500 mb-6">
<div class="title font-bold pb-3 pr-4">SSH Keys</div>
<label for="my-modal" class="btn btn-sm btn-primary" on:click={() => (isModalActive = true)}
>Add SSH Key</label
>
</div>
{#if sshKeys.length === 0}
<div class="text-sm">No SSH keys found</div>
{:else}
<div class="mx-auto w-full">
<div class="flex border-b border-coolgray-500 mb-6">
<div class="title font-bold pb-3 pr-4">SSH Keys</div>
<label for="my-modal" class="btn btn-sm btn-primary" on:click={() => (isModalActive = true)}
>Add SSH Key</label
>
</div>
<table class="table w-full">
<thead>
<tr>