feat: implement settings hub dashboard and update usage metrics to support unlimited plans
This commit is contained in:
parent
b47f2488d0
commit
fe0d432f05
27 changed files with 824 additions and 499 deletions
40
app/Http/Controllers/App/Settings/SettingsController.php
Normal file
40
app/Http/Controllers/App/Settings/SettingsController.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App\Settings;
|
||||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Http\Controllers\App\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$workspace = $user?->currentWorkspace;
|
||||
|
||||
$workspaceRole = $workspace
|
||||
? ($user->isAccountOwner() && $workspace->account_id === $user->account_id
|
||||
? 'owner'
|
||||
: $workspace->members()->where('users.id', $user->id)->first()?->pivot?->role)
|
||||
: null;
|
||||
|
||||
$canManageWorkspace = $workspace && in_array(
|
||||
$workspaceRole,
|
||||
['owner', WorkspaceRole::Admin->value],
|
||||
true,
|
||||
);
|
||||
|
||||
return Inertia::render('settings/Index', [
|
||||
'permissions' => [
|
||||
'canManageProfile' => true,
|
||||
'canManageWorkspace' => $canManageWorkspace,
|
||||
'canManageAccount' => $user?->isAccountOwner() && ! config('trypost.self_hosted'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,23 @@
|
|||
'title' => 'Settings',
|
||||
'description' => 'Manage your profile and account settings',
|
||||
|
||||
'hub' => [
|
||||
'title' => 'Settings',
|
||||
'description' => 'Choose what you want to manage.',
|
||||
'profile' => [
|
||||
'title' => 'Profile',
|
||||
'description' => 'Update your personal info, password, and notification preferences.',
|
||||
],
|
||||
'workspace' => [
|
||||
'title' => 'Workspace',
|
||||
'description' => 'Configure your workspace, brand, members, and API keys.',
|
||||
],
|
||||
'account' => [
|
||||
'title' => 'Account',
|
||||
'description' => 'Manage your account info, usage, and billing.',
|
||||
],
|
||||
],
|
||||
|
||||
'nav' => [
|
||||
'profile' => 'Profile',
|
||||
'password' => 'Password',
|
||||
|
|
@ -178,6 +195,11 @@
|
|||
],
|
||||
|
||||
'account' => [
|
||||
'tabs' => [
|
||||
'account' => 'Account',
|
||||
'usage' => 'Usage',
|
||||
'billing' => 'Billing',
|
||||
],
|
||||
'title' => 'Account Settings',
|
||||
'description' => 'Manage your account name and billing email',
|
||||
'name' => 'Account Name',
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@
|
|||
'groups' => [
|
||||
'posts' => 'Posts',
|
||||
'workspace' => 'Workspace',
|
||||
'account' => 'Account',
|
||||
'support' => 'Support',
|
||||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
'settings' => 'Settings',
|
||||
|
||||
'posts' => [
|
||||
'calendar' => 'Calendar',
|
||||
|
|
@ -42,13 +42,6 @@
|
|||
'labels' => 'Labels',
|
||||
'assets' => 'Assets',
|
||||
'api_keys' => 'API Keys',
|
||||
'settings' => 'Settings',
|
||||
],
|
||||
|
||||
'account' => [
|
||||
'settings' => 'Settings',
|
||||
'usage' => 'Usage',
|
||||
'billing' => 'Billing',
|
||||
],
|
||||
|
||||
'notifications' => 'Notifications',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@
|
|||
'title' => 'Configuración',
|
||||
'description' => 'Administra tu perfil y configuración de la cuenta',
|
||||
|
||||
'hub' => [
|
||||
'title' => 'Configuración',
|
||||
'description' => 'Elige qué quieres gestionar.',
|
||||
'profile' => [
|
||||
'title' => 'Perfil',
|
||||
'description' => 'Actualiza tu información personal, contraseña y preferencias de notificaciones.',
|
||||
],
|
||||
'workspace' => [
|
||||
'title' => 'Workspace',
|
||||
'description' => 'Configura tu workspace, marca, miembros y claves de API.',
|
||||
],
|
||||
'account' => [
|
||||
'title' => 'Cuenta',
|
||||
'description' => 'Gestiona la información de la cuenta, uso y facturación.',
|
||||
],
|
||||
],
|
||||
|
||||
'nav' => [
|
||||
'profile' => 'Perfil',
|
||||
'password' => 'Contraseña',
|
||||
|
|
@ -177,7 +194,24 @@
|
|||
],
|
||||
],
|
||||
|
||||
'account' => [
|
||||
'tabs' => [
|
||||
'account' => 'Cuenta',
|
||||
'usage' => 'Uso',
|
||||
'billing' => 'Facturación',
|
||||
],
|
||||
'title' => 'Configuración de cuenta',
|
||||
'description' => 'Gestiona el nombre de la cuenta y el correo de facturación',
|
||||
'name' => 'Nombre de la cuenta',
|
||||
'name_placeholder' => 'Mi Empresa',
|
||||
'billing_email' => 'Correo de facturación',
|
||||
'billing_email_placeholder' => 'facturacion@empresa.com',
|
||||
'billing_email_hint' => 'Este correo se usará para facturas y comunicaciones de facturación de Stripe.',
|
||||
'submit' => 'Guardar',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'account_updated' => '¡Cuenta actualizada correctamente!',
|
||||
'profile_updated' => '¡Perfil actualizado correctamente!',
|
||||
'language_updated' => '¡Idioma actualizado correctamente!',
|
||||
'password_updated' => '¡Contraseña actualizada correctamente!',
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@
|
|||
'groups' => [
|
||||
'posts' => 'Posts',
|
||||
'workspace' => 'Workspace',
|
||||
'account' => 'Cuenta',
|
||||
'support' => 'Soporte',
|
||||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
'settings' => 'Configuración',
|
||||
|
||||
'posts' => [
|
||||
'calendar' => 'Calendario',
|
||||
|
|
@ -42,13 +42,6 @@
|
|||
'labels' => 'Etiquetas',
|
||||
'assets' => 'Medios',
|
||||
'api_keys' => 'API Keys',
|
||||
'settings' => 'Configuración',
|
||||
],
|
||||
|
||||
'account' => [
|
||||
'settings' => 'Configuración',
|
||||
'usage' => 'Uso',
|
||||
'billing' => 'Facturación',
|
||||
],
|
||||
|
||||
'notifications' => 'Notificaciones',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,23 @@
|
|||
'title' => 'Configurações',
|
||||
'description' => 'Gerencie seu perfil e configurações da conta',
|
||||
|
||||
'hub' => [
|
||||
'title' => 'Configurações',
|
||||
'description' => 'Escolha o que você quer gerenciar.',
|
||||
'profile' => [
|
||||
'title' => 'Perfil',
|
||||
'description' => 'Atualize suas informações pessoais, senha e preferências de notificações.',
|
||||
],
|
||||
'workspace' => [
|
||||
'title' => 'Workspace',
|
||||
'description' => 'Configure seu workspace, marca, membros e chaves de API.',
|
||||
],
|
||||
'account' => [
|
||||
'title' => 'Conta',
|
||||
'description' => 'Gerencie informações da conta, uso e faturamento.',
|
||||
],
|
||||
],
|
||||
|
||||
'nav' => [
|
||||
'profile' => 'Perfil',
|
||||
'password' => 'Senha',
|
||||
|
|
@ -177,7 +194,24 @@
|
|||
],
|
||||
],
|
||||
|
||||
'account' => [
|
||||
'tabs' => [
|
||||
'account' => 'Conta',
|
||||
'usage' => 'Uso',
|
||||
'billing' => 'Faturamento',
|
||||
],
|
||||
'title' => 'Configurações da conta',
|
||||
'description' => 'Gerencie o nome da conta e o e-mail de cobrança',
|
||||
'name' => 'Nome da conta',
|
||||
'name_placeholder' => 'Minha Empresa',
|
||||
'billing_email' => 'E-mail de cobrança',
|
||||
'billing_email_placeholder' => 'cobranca@empresa.com',
|
||||
'billing_email_hint' => 'Este e-mail será usado para faturas e comunicações de cobrança do Stripe.',
|
||||
'submit' => 'Salvar',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'account_updated' => 'Conta atualizada com sucesso!',
|
||||
'profile_updated' => 'Perfil atualizado com sucesso!',
|
||||
'language_updated' => 'Idioma atualizado com sucesso!',
|
||||
'password_updated' => 'Senha atualizada com sucesso!',
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@
|
|||
'groups' => [
|
||||
'posts' => 'Posts',
|
||||
'workspace' => 'Workspace',
|
||||
'account' => 'Conta',
|
||||
'support' => 'Suporte',
|
||||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
'settings' => 'Configurações',
|
||||
|
||||
'posts' => [
|
||||
'calendar' => 'Calendário',
|
||||
|
|
@ -42,13 +42,6 @@
|
|||
'labels' => 'Etiquetas',
|
||||
'assets' => 'Mídias',
|
||||
'api_keys' => 'API Keys',
|
||||
'settings' => 'Configurações',
|
||||
],
|
||||
|
||||
'account' => [
|
||||
'settings' => 'Configurações',
|
||||
'usage' => 'Uso',
|
||||
'billing' => 'Faturamento',
|
||||
],
|
||||
|
||||
'notifications' => 'Notificações',
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import {
|
|||
IconChartBar,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconChartPie,
|
||||
IconCreditCard,
|
||||
IconFileCheck,
|
||||
IconFileText,
|
||||
IconHash,
|
||||
|
|
@ -23,7 +21,6 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import { computed } from 'vue';
|
||||
|
||||
import { create as createPost, index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
|
|
@ -46,14 +43,10 @@ import {
|
|||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { accounts, analytics, calendar } from '@/routes/app';
|
||||
import { edit as accountSettings } from '@/routes/app/account';
|
||||
import { index as billing } from '@/routes/app/billing';
|
||||
import { index as usage } from '@/routes/app/usage';
|
||||
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
|
||||
import { index as assets } from '@/routes/app/assets';
|
||||
import { index as signatures } from '@/routes/app/signatures';
|
||||
import { index as labels } from '@/routes/app/labels';
|
||||
import { settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
|
|
@ -64,9 +57,7 @@ interface Workspace {
|
|||
}
|
||||
|
||||
const page = usePage();
|
||||
const auth = computed(() => page.props.auth);
|
||||
const currentWorkspace = computed<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
|
||||
const isSelfHosted = computed(() => page.props.selfHosted as boolean);
|
||||
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
|
||||
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
|
@ -108,66 +99,26 @@ const postsNavItems = computed<NavItem[]>(() => [
|
|||
},
|
||||
]);
|
||||
|
||||
const isOwner = computed(() => {
|
||||
const role = auth.value.currentWorkspace?.role;
|
||||
return role === WorkspaceRole.Owner;
|
||||
});
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
const role = auth.value.currentWorkspace?.role;
|
||||
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
|
||||
});
|
||||
|
||||
const workspaceNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: trans('sidebar.workspace.connections'),
|
||||
href: accounts.url(),
|
||||
icon: IconAffiliate,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.signatures'),
|
||||
href: signatures.url(),
|
||||
icon: IconHash,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.labels'),
|
||||
href: labels.url(),
|
||||
icon: IconTag,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.assets'),
|
||||
href: assets.url(),
|
||||
icon: IconPhoto,
|
||||
},
|
||||
];
|
||||
|
||||
if (isAdmin.value) {
|
||||
items.push({
|
||||
title: trans('sidebar.workspace.settings'),
|
||||
href: workspaceSettings.url(),
|
||||
icon: IconSettings,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const accountNavItems = computed<NavItem[]>(() => [
|
||||
const workspaceNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: trans('sidebar.account.settings'),
|
||||
href: accountSettings.url(),
|
||||
icon: IconSettings,
|
||||
title: trans('sidebar.workspace.connections'),
|
||||
href: accounts.url(),
|
||||
icon: IconAffiliate,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.account.usage'),
|
||||
href: usage.url(),
|
||||
icon: IconChartPie,
|
||||
title: trans('sidebar.workspace.signatures'),
|
||||
href: signatures.url(),
|
||||
icon: IconHash,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.account.billing'),
|
||||
href: billing.url(),
|
||||
icon: IconCreditCard,
|
||||
title: trans('sidebar.workspace.labels'),
|
||||
href: labels.url(),
|
||||
icon: IconTag,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.assets'),
|
||||
href: assets.url(),
|
||||
icon: IconPhoto,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -239,11 +190,18 @@ const switchWorkspace = (workspaceId: string) => {
|
|||
<NavMain v-if="currentWorkspace" :items="mainNavItems" />
|
||||
<NavMain v-if="currentWorkspace" :items="postsNavItems" :label="$t('sidebar.groups.posts')" />
|
||||
<NavMain v-if="currentWorkspace" :items="workspaceNavItems" :label="$t('sidebar.groups.workspace')" />
|
||||
<NavMain v-if="currentWorkspace && isOwner && !isSelfHosted" :items="accountNavItems" :label="$t('sidebar.groups.account')" />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child :tooltip="trans('sidebar.settings')">
|
||||
<Link :href="settingsHub.url()">
|
||||
<IconSettings />
|
||||
<span>{{ $t('sidebar.settings') }}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child :tooltip="trans('sidebar.support.share_feedback')">
|
||||
<a href="https://github.com/trypost-it/trypost/discussions" target="_blank"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { formatNumber } from '@/lib/utils';
|
|||
type Props = {
|
||||
label: string;
|
||||
current: number;
|
||||
limit: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -14,14 +14,18 @@ const props = defineProps<Props>();
|
|||
const RING_RADIUS = 8;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
const hasLimit = computed(() => props.limit !== undefined);
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (props.limit === 0) {
|
||||
if (!hasLimit.value || props.limit === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, (props.current / props.limit) * 100);
|
||||
return Math.min(100, (props.current / (props.limit as number)) * 100);
|
||||
});
|
||||
|
||||
const isOverLimit = computed(() => props.current >= props.limit);
|
||||
const isOverLimit = computed(
|
||||
() => hasLimit.value && props.current >= (props.limit as number),
|
||||
);
|
||||
|
||||
const dashoffset = computed(
|
||||
() => RING_CIRCUMFERENCE * (1 - percentage.value / 100),
|
||||
|
|
@ -41,6 +45,7 @@ const dashoffset = computed(
|
|||
class="opacity-10"
|
||||
/>
|
||||
<circle
|
||||
v-if="hasLimit"
|
||||
cx="10"
|
||||
cy="10"
|
||||
:r="RING_RADIUS"
|
||||
|
|
@ -62,8 +67,8 @@ const dashoffset = computed(
|
|||
>
|
||||
{{ formatNumber(current) }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
/ {{ formatNumber(limit) }}
|
||||
<span v-if="hasLimit" class="text-muted-foreground">
|
||||
/ {{ formatNumber(limit as number) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ const props = defineProps<{
|
|||
<template>
|
||||
<main data-slot="sidebar-inset" :class="cn(
|
||||
'bg-card text-card-foreground relative flex w-full flex-1 flex-col overflow-y-auto',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs',
|
||||
props.class,
|
||||
)">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useActiveUrl } from '@/composables/useActiveUrl';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { preferences as notificationPreferences } from '@/routes/app/notifications';
|
||||
import { edit as editProfile } from '@/routes/app/profile';
|
||||
import { edit as editPassword } from '@/routes/app/user-password';
|
||||
import { type NavItem } from '@/types';
|
||||
|
||||
const navItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: trans('settings.nav.profile'),
|
||||
href: editProfile(),
|
||||
},
|
||||
{
|
||||
title: trans('settings.nav.password'),
|
||||
href: editPassword(),
|
||||
},
|
||||
{
|
||||
title: trans('settings.nav.notifications'),
|
||||
href: notificationPreferences(),
|
||||
},
|
||||
]);
|
||||
|
||||
const { urlIsActive } = useActiveUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl px-4 py-6">
|
||||
<nav
|
||||
class="mb-8 inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
|
||||
<Link v-for="item in navItems" :key="toUrl(item.href)" :href="item.href"
|
||||
class="inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="urlIsActive(item.href)
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50 hover:text-foreground'
|
||||
">
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<section class="space-y-12">
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
102
resources/js/pages/settings/Index.vue
Normal file
102
resources/js/pages/settings/Index.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { IconBuildingCommunity, IconChevronRight, IconCreditCard, IconUser } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { edit as accountEdit } from '@/routes/app/account';
|
||||
import { edit as profileEdit } from '@/routes/app/profile';
|
||||
import { settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Permissions {
|
||||
canManageProfile: boolean;
|
||||
canManageWorkspace: boolean;
|
||||
canManageAccount: boolean;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
permissions: Permissions;
|
||||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('settings.hub.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">
|
||||
{{ $t('settings.hub.title') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('settings.hub.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link
|
||||
v-if="permissions.canManageProfile"
|
||||
:href="profileEdit().url"
|
||||
class="group flex flex-col gap-4 rounded-xl border bg-card p-5 transition hover:border-foreground/30 hover:shadow-sm"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<IconUser class="size-5" />
|
||||
</div>
|
||||
<IconChevronRight class="size-4 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-medium">{{ $t('settings.hub.profile.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('settings.hub.profile.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
v-if="permissions.canManageWorkspace"
|
||||
:href="workspaceSettings().url"
|
||||
class="group flex flex-col gap-4 rounded-xl border bg-card p-5 transition hover:border-foreground/30 hover:shadow-sm"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<IconBuildingCommunity class="size-5" />
|
||||
</div>
|
||||
<IconChevronRight class="size-4 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-medium">{{ $t('settings.hub.workspace.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('settings.hub.workspace.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
v-if="permissions.canManageAccount"
|
||||
:href="accountEdit().url"
|
||||
class="group flex flex-col gap-4 rounded-xl border bg-card p-5 transition hover:border-foreground/30 hover:shadow-sm"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<IconCreditCard class="size-5" />
|
||||
</div>
|
||||
<IconChevronRight class="size-4 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-medium">{{ $t('settings.hub.account.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('settings.hub.account.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -1,73 +1,96 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { update as accountUpdate } from '@/routes/app/account';
|
||||
import { settings as settingsHub } from '@/routes/app';
|
||||
import { edit as accountEdit, update as accountUpdate } from '@/routes/app/account';
|
||||
import { index as billingIndex } from '@/routes/app/billing';
|
||||
import { index as usageIndex } from '@/routes/app/usage';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface AccountData {
|
||||
id: string;
|
||||
name: string;
|
||||
billing_email: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
account: AccountData;
|
||||
selfHosted: boolean;
|
||||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.account.title') },
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'account', label: trans('settings.account.tabs.account'), href: accountEdit().url },
|
||||
{ name: 'usage', label: trans('settings.account.tabs.usage'), href: usageIndex().url },
|
||||
{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="$t('settings.account.title')">
|
||||
<Head :title="$t('settings.account.title')" />
|
||||
<Head :title="$t('settings.account.title')" />
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.account.title')"
|
||||
:description="$t('settings.account.description')"
|
||||
/>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<SettingsTabsNav :tabs="tabs" active="account" />
|
||||
|
||||
<Form
|
||||
v-bind="accountUpdate.form()"
|
||||
method="put"
|
||||
v-slot="{ errors, processing }"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="account-name">{{ $t('settings.account.name') }}</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
name="name"
|
||||
:default-value="account.name"
|
||||
:placeholder="trans('settings.account.name_placeholder')"
|
||||
<section class="space-y-12">
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.account.title')"
|
||||
:description="$t('settings.account.description')"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div v-if="!selfHosted" class="grid gap-2">
|
||||
<Label for="billing-email">{{ $t('settings.account.billing_email') }}</Label>
|
||||
<Input
|
||||
id="billing-email"
|
||||
name="billing_email"
|
||||
type="email"
|
||||
:default-value="account.billing_email"
|
||||
:placeholder="trans('settings.account.billing_email_placeholder')"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.account.billing_email_hint') }}
|
||||
</p>
|
||||
<InputError :message="errors.billing_email" />
|
||||
</div>
|
||||
<Form
|
||||
v-bind="accountUpdate.form()"
|
||||
method="put"
|
||||
v-slot="{ errors, processing }"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="account-name">{{ $t('settings.account.name') }}</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
name="name"
|
||||
:default-value="account.name"
|
||||
:placeholder="trans('settings.account.name_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
{{ $t('settings.account.submit') }}
|
||||
</Button>
|
||||
</Form>
|
||||
<div v-if="!selfHosted" class="grid gap-2">
|
||||
<Label for="billing-email">{{ $t('settings.account.billing_email') }}</Label>
|
||||
<Input
|
||||
id="billing-email"
|
||||
name="billing_email"
|
||||
type="email"
|
||||
:default-value="account.billing_email"
|
||||
:placeholder="trans('settings.account.billing_email_placeholder')"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.account.billing_email_hint') }}
|
||||
</p>
|
||||
<InputError :message="errors.billing_email" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
{{ $t('settings.account.submit') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,18 @@
|
|||
import { Head } from '@inertiajs/vue3';
|
||||
import { IconDownload, IconFileText } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { portal } from '@/routes/app/billing';
|
||||
import { settings as settingsHub } from '@/routes/app';
|
||||
import { edit as accountEdit } from '@/routes/app/account';
|
||||
import { index as billingIndex, portal } from '@/routes/app/billing';
|
||||
import { index as usageIndex } from '@/routes/app/usage';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Plan {
|
||||
name: string;
|
||||
slug: string;
|
||||
|
|
@ -32,7 +39,7 @@ interface Invoice {
|
|||
invoice_pdf: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
hasSubscription: boolean;
|
||||
onTrial: boolean;
|
||||
trialEndsAt: string | null;
|
||||
|
|
@ -43,6 +50,18 @@ const props = defineProps<{
|
|||
defaultPaymentMethod: PaymentMethod | null;
|
||||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.account.title'), href: accountEdit().url },
|
||||
{ title: trans('settings.account.tabs.billing') },
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'account', label: trans('settings.account.tabs.account'), href: accountEdit().url },
|
||||
{ name: 'usage', label: trans('settings.account.tabs.usage'), href: usageIndex().url },
|
||||
{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url },
|
||||
]);
|
||||
|
||||
const monthlyPrice = (slug: string | undefined): string => {
|
||||
if (! slug) return 'Free';
|
||||
return trans(`billing.subscribe.prices.${slug}.monthly`);
|
||||
|
|
@ -52,55 +71,53 @@ const monthlyPrice = (slug: string | undefined): string => {
|
|||
<template>
|
||||
<Head :title="$t('billing.title')" />
|
||||
|
||||
<AppLayout :title="$t('billing.title')">
|
||||
<div class="mx-auto max-w-3xl space-y-0 p-6">
|
||||
<!-- Plan -->
|
||||
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.plan.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('billing.plan.description') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('billing.plan.change') }}
|
||||
</Button>
|
||||
</div>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<SettingsTabsNav :tabs="tabs" active="billing" />
|
||||
|
||||
<div class="divide-y">
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<span>{{ $t('billing.plan.label') }}</span>
|
||||
<Badge v-if="onTrial" variant="secondary">{{ $t('billing.plan.trial') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'active'" variant="default">{{ $t('billing.plan.active') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'past_due'" variant="destructive">{{ $t('billing.plan.past_due') }}</Badge>
|
||||
<Badge v-else-if="subscription?.ends_at" variant="secondary">{{ $t('billing.plan.cancelling') }}</Badge>
|
||||
</span>
|
||||
<span class="ml-auto text-sm font-medium">{{ plan?.name ?? 'No plan' }}</span>
|
||||
<section class="space-y-12">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.plan.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('billing.plan.description') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ $t('billing.plan.change') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.price') }}</span>
|
||||
<span class="ml-auto text-sm font-medium tabular-nums">
|
||||
{{ monthlyPrice(plan?.slug) }}/{{ $t('billing.plan.month') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<span>{{ $t('billing.plan.label') }}</span>
|
||||
<Badge v-if="onTrial" variant="secondary">{{ $t('billing.plan.trial') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'active'" variant="default">{{ $t('billing.plan.active') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'past_due'" variant="destructive">{{ $t('billing.plan.past_due') }}</Badge>
|
||||
<Badge v-else-if="subscription?.ends_at" variant="secondary">{{ $t('billing.plan.cancelling') }}</Badge>
|
||||
</span>
|
||||
<span class="ml-auto text-sm font-medium">{{ plan?.name ?? 'No plan' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="onTrial && trialEndsAt" class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.trial_ends') }}</span>
|
||||
<span class="ml-auto text-sm font-medium">{{ trialEndsAt }}</span>
|
||||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.price') }}</span>
|
||||
<span class="ml-auto text-sm font-medium tabular-nums">
|
||||
{{ monthlyPrice(plan?.slug) }}/{{ $t('billing.plan.month') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="onTrial && trialEndsAt" class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.trial_ends') }}</span>
|
||||
<span class="ml-auto text-sm font-medium">{{ trialEndsAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subscription Management -->
|
||||
<template v-if="hasSubscription">
|
||||
<hr class="my-8 border-border" />
|
||||
|
||||
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div v-if="hasSubscription" class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.subscription.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
|
|
@ -128,14 +145,9 @@ const monthlyPrice = (slug: string | undefined): string => {
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Invoices -->
|
||||
<template v-if="invoices.length > 0">
|
||||
<hr class="my-8 border-border" />
|
||||
|
||||
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div v-if="invoices.length > 0" class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('billing.invoices.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
|
|
@ -169,8 +181,8 @@ const monthlyPrice = (slug: string | undefined): string => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import UsageMetricRow from '@/components/settings/UsageMetricRow.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { settings as settingsHub } from '@/routes/app';
|
||||
import { edit as accountEdit } from '@/routes/app/account';
|
||||
import { index as billingIndex } from '@/routes/app/billing';
|
||||
import { index as usageIndex } from '@/routes/app/usage';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Plan {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface Usage {
|
||||
interface UsageData {
|
||||
workspaceCount: number;
|
||||
workspaceLimit: number;
|
||||
socialAccountCount: number;
|
||||
|
|
@ -19,69 +29,81 @@ interface Usage {
|
|||
aiTextUsed: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
plan: Plan | null;
|
||||
usage: Usage;
|
||||
usage: UsageData;
|
||||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.account.title'), href: accountEdit().url },
|
||||
{ title: trans('settings.account.tabs.usage') },
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'account', label: trans('settings.account.tabs.account'), href: accountEdit().url },
|
||||
{ name: 'usage', label: trans('settings.account.tabs.usage'), href: usageIndex().url },
|
||||
{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('usage.title')" />
|
||||
|
||||
<AppLayout :title="$t('usage.title')">
|
||||
<div class="mx-auto max-w-3xl space-y-0 p-6">
|
||||
<!-- Account -->
|
||||
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('usage.section_account') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('usage.section_account_description', { plan: plan?.name ?? 'Free' }) }}
|
||||
</p>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<SettingsTabsNav :tabs="tabs" active="usage" />
|
||||
|
||||
<section class="space-y-12">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('usage.section_account') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('usage.section_account_description', { plan: plan?.name ?? 'Free' }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.workspaces')"
|
||||
:current="usage.workspaceCount"
|
||||
:limit="usage.workspaceLimit"
|
||||
/>
|
||||
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.social_accounts')"
|
||||
:current="usage.socialAccountCount"
|
||||
:limit="usage.socialAccountLimit"
|
||||
/>
|
||||
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.members')"
|
||||
:current="usage.memberCount"
|
||||
:limit="usage.memberLimit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.workspaces')"
|
||||
:current="usage.workspaceCount"
|
||||
:limit="usage.workspaceLimit"
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('usage.section_ai') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('usage.section_ai_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.social_accounts')"
|
||||
:current="usage.socialAccountCount"
|
||||
:limit="usage.socialAccountLimit"
|
||||
/>
|
||||
<div class="divide-y">
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.ai_images')"
|
||||
:current="usage.aiImagesUsed"
|
||||
:limit="usage.aiImagesLimit"
|
||||
/>
|
||||
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.members')"
|
||||
:current="usage.memberCount"
|
||||
:limit="usage.memberLimit"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-8 border-border" />
|
||||
|
||||
<!-- AI -->
|
||||
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">{{ $t('usage.section_ai') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('usage.section_ai_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.ai_images')"
|
||||
:current="usage.aiImagesUsed"
|
||||
:limit="usage.aiImagesLimit"
|
||||
/>
|
||||
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.ai_text')"
|
||||
:current="usage.aiTextUsed"
|
||||
/>
|
||||
<UsageMetricRow
|
||||
:label="$t('usage.ai_text')"
|
||||
:current="usage.aiTextUsed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { settings as settingsHub } from '@/routes/app';
|
||||
import { preferences as preferencesRoute } from '@/routes/app/notifications';
|
||||
import { edit as editProfile } from '@/routes/app/profile';
|
||||
import { edit as editPassword } from '@/routes/app/user-password';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Preferences {
|
||||
post_published: boolean;
|
||||
post_failed: boolean;
|
||||
|
|
@ -26,6 +32,18 @@ const postFailed = ref(props.preferences.post_failed);
|
|||
const accountDisconnected = ref(props.preferences.account_disconnected);
|
||||
const processing = ref(false);
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.profile.title'), href: editProfile().url },
|
||||
{ title: trans('settings.nav.notifications') },
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
|
||||
{ name: 'password', label: trans('settings.nav.password'), href: editPassword().url },
|
||||
{ name: 'notifications', label: trans('settings.nav.notifications'), href: preferencesRoute().url },
|
||||
]);
|
||||
|
||||
const submit = () => {
|
||||
processing.value = true;
|
||||
|
||||
|
|
@ -43,54 +61,56 @@ const submit = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="$t('settings.notifications.title')">
|
||||
<Head :title="$t('settings.notifications.title')" />
|
||||
<Head :title="$t('settings.notifications.title')" />
|
||||
|
||||
<h1 class="sr-only">{{ $t('settings.notifications.title') }}</h1>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<SettingsTabsNav :tabs="tabs" active="notifications" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.notifications.heading')"
|
||||
:description="$t('settings.notifications.description')"
|
||||
/>
|
||||
<section class="space-y-12">
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.notifications.heading')"
|
||||
:description="$t('settings.notifications.description')"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="post_published">{{ $t('settings.notifications.post_published') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.post_published_description') }}
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="post_published">{{ $t('settings.notifications.post_published') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.post_published_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="post_published" v-model="postPublished" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="post_failed">{{ $t('settings.notifications.post_failed') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.post_failed_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="post_failed" v-model="postFailed" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="account_disconnected">{{ $t('settings.notifications.account_disconnected') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.account_disconnected_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="account_disconnected" v-model="accountDisconnected" />
|
||||
</div>
|
||||
<Switch id="post_published" v-model="postPublished" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="post_failed">{{ $t('settings.notifications.post_failed') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.post_failed_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="post_failed" v-model="postFailed" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="account_disconnected">{{ $t('settings.notifications.account_disconnected') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.account_disconnected_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="account_disconnected" v-model="accountDisconnected" />
|
||||
</div>
|
||||
<Button :disabled="processing" class="self-start" @click="submit">
|
||||
{{ $t('settings.notifications.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing" class="self-start" @click="submit">
|
||||
{{ $t('settings.notifications.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,87 +1,108 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import PasswordController from '@/actions/App/Http/Controllers/App/Settings/PasswordController';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { settings as settingsHub } from '@/routes/app';
|
||||
import { preferences as notificationPreferences } from '@/routes/app/notifications';
|
||||
import { edit as editProfile } from '@/routes/app/profile';
|
||||
import { edit as editPassword } from '@/routes/app/user-password';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.profile.title'), href: editProfile().url },
|
||||
{ title: trans('settings.nav.password') },
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
|
||||
{ name: 'password', label: trans('settings.nav.password'), href: editPassword().url },
|
||||
{ name: 'notifications', label: trans('settings.nav.notifications'), href: notificationPreferences().url },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="$t('settings.password.title')">
|
||||
<Head :title="$t('settings.password.title')" />
|
||||
<Head :title="$t('settings.password.title')" />
|
||||
|
||||
<h1 class="sr-only">{{ $t('settings.password.title') }}</h1>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<SettingsTabsNav :tabs="tabs" active="password" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.password.heading')"
|
||||
:description="$t('settings.password.description')"
|
||||
/>
|
||||
<section class="space-y-12">
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.password.heading')"
|
||||
:description="$t('settings.password.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="PasswordController.update.form()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
reset-on-success
|
||||
:reset-on-error="[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="current_password">{{ $t('settings.password.current_password') }}</Label>
|
||||
<Input
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="trans('settings.password.current_password_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.current_password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">{{ $t('settings.password.new_password') }}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="trans('settings.password.new_password_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation">{{ $t('settings.password.confirm_password') }}</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="trans('settings.password.confirm_password_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:disabled="processing"
|
||||
data-test="update-password-button"
|
||||
<Form
|
||||
v-bind="PasswordController.update.form()"
|
||||
:options="{
|
||||
preserveScroll: true,
|
||||
}"
|
||||
reset-on-success
|
||||
:reset-on-error="[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
{{ $t('settings.password.save') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
<div class="grid gap-2">
|
||||
<Label for="current_password">{{ $t('settings.password.current_password') }}</Label>
|
||||
<Input
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="trans('settings.password.current_password_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.current_password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">{{ $t('settings.password.new_password') }}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="trans('settings.password.new_password_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation">{{ $t('settings.password.confirm_password') }}</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="trans('settings.password.confirm_password_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:disabled="processing"
|
||||
data-test="update-password-button"
|
||||
>
|
||||
{{ $t('settings.password.save') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,19 @@ import DeleteUser from '@/components/DeleteUser.vue';
|
|||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PhotoUpload from '@/components/PhotoUpload.vue';
|
||||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { uploadPhoto, deletePhoto } from '@/routes/app/profile';
|
||||
import { settings as settingsHub } from '@/routes/app';
|
||||
import { preferences as notificationPreferences } from '@/routes/app/notifications';
|
||||
import { deletePhoto, edit as editProfile, uploadPhoto } from '@/routes/app/profile';
|
||||
import { edit as editPassword } from '@/routes/app/user-password';
|
||||
import { send } from '@/routes/verification';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Props {
|
||||
mustVerifyEmail: boolean;
|
||||
status?: string;
|
||||
|
|
@ -25,100 +30,113 @@ defineProps<Props>();
|
|||
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.profile.title') },
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
|
||||
{ name: 'password', label: trans('settings.nav.password'), href: editPassword().url },
|
||||
{ name: 'notifications', label: trans('settings.nav.notifications'), href: notificationPreferences().url },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="$t('settings.profile.title')">
|
||||
<Head :title="$t('settings.profile.title')" />
|
||||
<Head :title="$t('settings.profile.title')" />
|
||||
|
||||
<h1 class="sr-only">{{ $t('settings.profile.title') }}</h1>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
|
||||
<SettingsTabsNav :tabs="tabs" active="profile" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.profile.photo_heading')"
|
||||
:description="$t('settings.profile.photo_description')"
|
||||
/>
|
||||
<section class="space-y-12">
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.profile.photo_heading')"
|
||||
:description="$t('settings.profile.photo_description')"
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
:photo-url="user.photo_url"
|
||||
:has-photo="user.has_photo"
|
||||
:name="user.name"
|
||||
:upload-url="uploadPhoto().url"
|
||||
:delete-url="deletePhoto().url"
|
||||
/>
|
||||
</div>
|
||||
<PhotoUpload
|
||||
:photo-url="user.photo_url"
|
||||
:has-photo="user.has_photo"
|
||||
:name="user.name"
|
||||
:upload-url="uploadPhoto().url"
|
||||
:delete-url="deletePhoto().url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.profile.heading')"
|
||||
:description="$t('settings.profile.description')"
|
||||
/>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.profile.heading')"
|
||||
:description="$t('settings.profile.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="ProfileController.update.form()"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">{{ $t('settings.profile.name') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
:default-value="user.name"
|
||||
autocomplete="name"
|
||||
:placeholder="trans('settings.profile.name_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">{{ $t('settings.profile.email') }}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
:default-value="user.email"
|
||||
autocomplete="username"
|
||||
:placeholder="trans('settings.profile.email_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div v-if="mustVerifyEmail && !user.email_verified_at">
|
||||
<p class="-mt-4 text-sm text-muted-foreground">
|
||||
{{ $t('settings.profile.email_unverified') }}
|
||||
<Link
|
||||
:href="send()"
|
||||
as="button"
|
||||
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
{{ $t('settings.profile.resend_verification') }}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="status === 'verification-link-sent'"
|
||||
class="mt-2 text-sm font-medium text-green-600"
|
||||
>
|
||||
{{ $t('settings.profile.verification_sent') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:disabled="processing"
|
||||
data-test="update-profile-button"
|
||||
<Form
|
||||
v-bind="ProfileController.update.form()"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
{{ $t('settings.profile.save') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">{{ $t('settings.profile.name') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
:default-value="user.name"
|
||||
autocomplete="name"
|
||||
:placeholder="trans('settings.profile.name_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">{{ $t('settings.profile.email') }}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
:default-value="user.email"
|
||||
autocomplete="username"
|
||||
:placeholder="trans('settings.profile.email_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<DeleteUser />
|
||||
</SettingsLayout>
|
||||
<div v-if="mustVerifyEmail && !user.email_verified_at">
|
||||
<p class="-mt-4 text-sm text-muted-foreground">
|
||||
{{ $t('settings.profile.email_unverified') }}
|
||||
<Link
|
||||
:href="send()"
|
||||
as="button"
|
||||
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
{{ $t('settings.profile.resend_verification') }}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="status === 'verification-link-sent'"
|
||||
class="mt-2 text-sm font-medium text-green-600"
|
||||
>
|
||||
{{ $t('settings.profile.verification_sent') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:disabled="processing"
|
||||
data-test="update-profile-button"
|
||||
>
|
||||
{{ $t('settings.profile.save') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DeleteUser />
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
import date from '@/date';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { copyToClipboard } from '@/lib/utils';
|
||||
import { members as membersRoute } from '@/routes/app';
|
||||
import { members as membersRoute, settings as settingsHub } from '@/routes/app';
|
||||
import { index as apiKeysRoute } from '@/routes/app/api-keys';
|
||||
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
|
@ -57,6 +57,7 @@ const createDialogOpen = ref(false);
|
|||
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.workspace.title'), href: workspaceSettings.url() },
|
||||
{ title: trans('settings.workspace.tabs.api_keys') },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import BrandTab from '@/components/settings/BrandTab.vue';
|
|||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as apiKeysRoute } from '@/routes/app/api-keys';
|
||||
import { members as membersRoute } from '@/routes/app';
|
||||
import { members as membersRoute, settings as settingsHub } from '@/routes/app';
|
||||
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
|
|
@ -31,6 +31,7 @@ defineProps<{
|
|||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.workspace.title'), href: workspaceSettings.url() },
|
||||
{ title: trans('settings.workspace.tabs.brand') },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { computed } from 'vue';
|
|||
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
||||
import UsersTab from '@/components/settings/UsersTab.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { members as membersRoute } from '@/routes/app';
|
||||
import { members as membersRoute, settings as settingsHub } from '@/routes/app';
|
||||
import { index as apiKeysRoute } from '@/routes/app/api-keys';
|
||||
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
|
@ -43,6 +43,7 @@ defineProps<{
|
|||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.workspace.title'), href: workspaceSettings.url() },
|
||||
{ title: trans('settings.workspace.tabs.users') },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
|||
import WorkspaceTab from '@/components/settings/WorkspaceTab.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as apiKeysRoute } from '@/routes/app/api-keys';
|
||||
import { members as membersRoute } from '@/routes/app';
|
||||
import { members as membersRoute, settings as settingsHub } from '@/routes/app';
|
||||
import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ defineProps<{
|
|||
}>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.hub.title'), href: settingsHub().url },
|
||||
{ title: trans('settings.workspace.title') },
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
use App\Http\Controllers\App\Settings\NotificationPreferenceController;
|
||||
use App\Http\Controllers\App\Settings\PasswordController;
|
||||
use App\Http\Controllers\App\Settings\ProfileController;
|
||||
use App\Http\Controllers\App\Settings\SettingsController;
|
||||
use App\Http\Controllers\App\Settings\UsageController;
|
||||
use App\Http\Controllers\App\UnsplashController;
|
||||
use App\Http\Controllers\App\WorkspaceController;
|
||||
|
|
@ -229,6 +230,8 @@
|
|||
|
||||
// Settings (auth required)
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('settings', [SettingsController::class, 'index'])->name('app.settings');
|
||||
|
||||
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('app.profile.edit');
|
||||
Route::put('settings/profile', [ProfileController::class, 'update'])->name('app.profile.update');
|
||||
Route::post('settings/profile/photo', [ProfileController::class, 'uploadPhoto'])->name('app.profile.upload-photo');
|
||||
|
|
|
|||
77
tests/Feature/Settings/SettingsHubTest.php
Normal file
77
tests/Feature/Settings/SettingsHubTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
test('settings hub requires authentication', function () {
|
||||
$this->get(route('app.settings'))->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('account owner with admin workspace role sees all three cards', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)->get(route('app.settings'))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('settings/Index')
|
||||
->where('permissions.canManageProfile', true)
|
||||
->where('permissions.canManageWorkspace', true)
|
||||
->where('permissions.canManageAccount', true)
|
||||
);
|
||||
});
|
||||
|
||||
test('plain workspace member only sees profile card', function () {
|
||||
$owner = User::factory()->create();
|
||||
$ownerWorkspace = Workspace::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
$member = User::factory()->create();
|
||||
$ownerWorkspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $ownerWorkspace->id]);
|
||||
|
||||
$this->actingAs($member)->get(route('app.settings'))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('permissions.canManageProfile', true)
|
||||
->where('permissions.canManageWorkspace', false)
|
||||
->where('permissions.canManageAccount', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace admin who is not account owner sees profile and workspace only', function () {
|
||||
$owner = User::factory()->create();
|
||||
$ownerWorkspace = Workspace::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$ownerWorkspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
|
||||
$admin->update(['current_workspace_id' => $ownerWorkspace->id]);
|
||||
|
||||
$this->actingAs($admin)->get(route('app.settings'))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('permissions.canManageProfile', true)
|
||||
->where('permissions.canManageWorkspace', true)
|
||||
->where('permissions.canManageAccount', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('account card is hidden when self hosted', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$this->actingAs($user)->get(route('app.settings'))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('permissions.canManageAccount', false)
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue