diff --git a/ui/src/components/AuthFormField.tsx b/ui/src/components/AuthFormField.tsx new file mode 100644 index 0000000..8137d06 --- /dev/null +++ b/ui/src/components/AuthFormField.tsx @@ -0,0 +1,36 @@ +import { JSXElement, Show, JSX } from 'solid-js' + +type InputAttrs = JSX.InputHTMLAttributes & { + 'data-cy'?: string +} + +interface AuthFormFieldProps { + label?: string + icon: JSXElement + error?: string + inputProps: InputAttrs +} + +/** + * Auth-page input field that pairs an icon, an input, and an inline error. + * Used by the Login and Register pages, which share the `register-*` styles. + */ +export function AuthFormField(props: AuthFormFieldProps): JSXElement { + return ( +
+ + + +
+ {props.icon} + +
+ +
{props.error}
+
+
+ ) +} diff --git a/ui/src/components/BillingStatCard.tsx b/ui/src/components/BillingStatCard.tsx new file mode 100644 index 0000000..3f0e733 --- /dev/null +++ b/ui/src/components/BillingStatCard.tsx @@ -0,0 +1,21 @@ +import clsx from 'clsx' +import { JSXElement } from 'solid-js' + +interface BillingStatCardProps { + value: JSXElement + label: JSXElement + /** Extra classes for the value text (e.g. to colour the trial-days countdown). */ + valueClass?: string +} + +/** One cell of the 3-column stats grid shown inside billing plan cards. */ +export function BillingStatCard(props: BillingStatCardProps): JSXElement { + return ( +
+

+ {props.value} +

+

{props.label}

+
+ ) +} diff --git a/ui/src/components/ConfirmModal.tsx b/ui/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..2f5be2e --- /dev/null +++ b/ui/src/components/ConfirmModal.tsx @@ -0,0 +1,59 @@ +import { JSXElement, Show } from 'solid-js' + +import { Alert } from './Alert' +import { Button } from './Button' +import { Modal, ModalBaseProps } from './Modal' +import { useLocale } from '../context/LocaleProvider' + +type DaisyUIColor = 'primary' | 'secondary' | 'error' | 'warning' | 'success' + +interface ConfirmModalProps extends ModalBaseProps { + title: string + message?: JSXElement + confirmLabel: string + cancelLabel?: string + confirmColor?: DaisyUIColor + isLoading?: boolean + errorMessage?: string | null + confirmDataCy?: string + onConfirm: () => void + children?: JSXElement +} + +/** + * Generic confirmation modal used for destructive/irreversible actions + * (delete, cancel subscription, leave workspace, remove member, etc.). + */ +export function ConfirmModal(props: ConfirmModalProps): JSXElement { + const { t } = useLocale() + + return ( + + +

{props.message}

+
+ + {props.children} + + + + + + +
+ ) +} diff --git a/ui/src/components/MarketingAuthLayout.tsx b/ui/src/components/MarketingAuthLayout.tsx new file mode 100644 index 0000000..497378d --- /dev/null +++ b/ui/src/components/MarketingAuthLayout.tsx @@ -0,0 +1,60 @@ +import { JSXElement, ParentProps } from 'solid-js' +import { A } from '@solidjs/router' + +import { AppLogo } from './auth-icons' +import { useLocale } from '../context/LocaleProvider' + +interface MarketingAuthLayoutProps extends ParentProps { + /** Right-side content of the topbar (e.g. "already have an account?" link, invite badge). */ + topbarRight?: JSXElement + /** Optional override for the left-side logo block. */ + logo?: JSXElement + /** Headline content displayed on the marketing (left) side. */ + headline: JSXElement + /** Subtitle text rendered below the headline. */ + subtitle: JSXElement + /** Right-side panel content (form, header, etc.). */ + formPanel: JSXElement +} + +/** + * Two-column marketing/auth layout used by the public Login and Register pages. + * The left column shows branding/headline, the right column hosts the form. + */ +export function MarketingAuthLayout( + props: MarketingAuthLayoutProps +): JSXElement { + const { t } = useLocale() + + return ( +
+ + +
+
+
+
+

{props.headline}

+

{props.subtitle}

+
+
+ +
{props.formPanel}
+
+
+
+ ) +} diff --git a/ui/src/components/Pagination.tsx b/ui/src/components/Pagination.tsx index 27addf9..5fb6ad4 100644 --- a/ui/src/components/Pagination.tsx +++ b/ui/src/components/Pagination.tsx @@ -2,6 +2,7 @@ import { JSXElement, Show } from 'solid-js' import { useLocale } from '../context/LocaleProvider' import { IconButton } from './Button' +import { Spinner } from './Spinner' interface PaginationProps { page: number @@ -30,7 +31,7 @@ export function Pagination(props: PaginationProps): JSXElement {

} + fallback={} > {props.totalPages} diff --git a/ui/src/components/PlanBadge.tsx b/ui/src/components/PlanBadge.tsx new file mode 100644 index 0000000..4341860 --- /dev/null +++ b/ui/src/components/PlanBadge.tsx @@ -0,0 +1,31 @@ +import clsx from 'clsx' +import { JSXElement } from 'solid-js' + +type PlanBadgeColor = 'success' | 'primary' + +const COLOR_CLASSES: Record = { + success: 'text-success bg-success/15', + primary: 'text-primary bg-primary/15', +} + +interface PlanBadgeProps { + label?: string + color?: PlanBadgeColor + size?: 'sm' | 'md' +} + +/** Small "Pro" pill shown next to paid/exempt workspaces. */ +export function PlanBadge(props: PlanBadgeProps): JSXElement { + return ( + + + {props.label ?? 'Pro'} + + ) +} diff --git a/ui/src/components/RegisterModal.tsx b/ui/src/components/RegisterModal.tsx index 1663340..a18e1bb 100644 --- a/ui/src/components/RegisterModal.tsx +++ b/ui/src/components/RegisterModal.tsx @@ -1,7 +1,7 @@ import { JSXElement, Show } from 'solid-js' import { useNavigate } from '@solidjs/router' -import { minLength, pattern, email, required } from '@modular-forms/solid' +import { email, required } from '@modular-forms/solid' import { register, RegisterUserData } from '../api' @@ -14,7 +14,7 @@ import { Modal, ModalBaseProps } from './Modal' import { Alert } from './Alert' import { Button } from './Button' import { createFormState } from '../form_helpers' -import { mustMatch } from '../validators' +import { mustMatch, passwordRules } from '../validators' export function RegisterModal(props: ModalBaseProps): JSXElement { const { t } = useLocale() @@ -68,16 +68,7 @@ export function RegisterModal(props: ModalBaseProps): JSXElement { )} - + {(field, props) => ( + ) +} + +export function FullScreenSpinner(props: SpinnerProps): JSXElement { + return ( +

+ +
+ ) +} + +export function CenteredSpinner(props: SpinnerProps): JSXElement { + return ( +
+ +
+ ) +} diff --git a/ui/src/components/StatusBanner.tsx b/ui/src/components/StatusBanner.tsx new file mode 100644 index 0000000..d3f0048 --- /dev/null +++ b/ui/src/components/StatusBanner.tsx @@ -0,0 +1,47 @@ +import clsx from 'clsx' +import { JSXElement } from 'solid-js' + +type BannerType = 'info' | 'success' | 'warning' | 'error' + +const BANNER_CONFIG: Record = { + info: { tint: 'bg-info/10 border-info/20 text-info', icon: 'fa-circle-info' }, + success: { + tint: 'bg-primary/10 border-primary/20 text-primary', + icon: 'fa-circle-check', + }, + warning: { + tint: 'bg-warning/10 border-warning/20 text-warning', + icon: 'fa-circle-exclamation', + }, + error: { + tint: 'bg-error/10 border-error/20 text-error', + icon: 'fa-circle-xmark', + }, +} + +interface StatusBannerProps { + type: BannerType + message: JSXElement + class?: string +} + +/** + * Compact inline status pill (border + tinted background) used for + * billing/checkout feedback messages. + */ +export function StatusBanner(props: StatusBannerProps): JSXElement { + const cfg = () => BANNER_CONFIG[props.type] + + return ( +
+ + {props.message} +
+ ) +} diff --git a/ui/src/components/WorkspaceAvatar.tsx b/ui/src/components/WorkspaceAvatar.tsx new file mode 100644 index 0000000..516f431 --- /dev/null +++ b/ui/src/components/WorkspaceAvatar.tsx @@ -0,0 +1,64 @@ +import clsx from 'clsx' +import { JSXElement } from 'solid-js' + +type AvatarSize = 'sm' | 'md' | 'lg' + +const SIZE_CLASSES: Record = { + sm: 'w-5 h-5 text-xs', + md: 'w-6 h-6 text-xs', + lg: 'w-10 h-10 md:w-12 md:h-12 text-base md:text-xl', +} + +interface WorkspaceAvatarProps { + name: string | undefined + color?: string | null + size?: AvatarSize + /** Use a softer fallback (used by WorkspaceSwitcher's "other workspaces" rows). */ + mutedFallback?: boolean + shape?: 'rounded' | 'square' + class?: string +} + +/** + * Coloured square showing the first letter of a workspace name. + * Used by the workspace switcher and workspace settings modal. + */ +export function WorkspaceAvatar(props: WorkspaceAvatarProps): JSXElement { + const initial = () => + props.name && props.name.length > 0 ? props.name.charAt(0) : '?' + + const hasColor = () => !!props.color + const radius = () => + props.shape === 'square' + ? props.size === 'lg' + ? 'rounded-2xl' + : 'rounded-xl' + : 'rounded' + + return ( +
+ + {initial()} + +
+ ) +} diff --git a/ui/src/components/WorkspaceSwitcher.tsx b/ui/src/components/WorkspaceSwitcher.tsx index a304063..dccc98c 100644 --- a/ui/src/components/WorkspaceSwitcher.tsx +++ b/ui/src/components/WorkspaceSwitcher.tsx @@ -16,6 +16,7 @@ import { WorkspaceListItemAttributes } from '../models/Workspace' import { createModalState } from './Modal' import { WorkspaceSettingsModal } from '../pages/workspace_modals/WorkspaceSettingsModal' import { Alert } from './Alert' +import { WorkspaceAvatar } from './WorkspaceAvatar' import { createWorkspace, getErrorMessage } from '../api' export function WorkspaceSwitcher(): JSXElement { @@ -126,22 +127,7 @@ export function WorkspaceSwitcher(): JSXElement { onClick={() => setOpen((v) => !v)} >
-
- - {ws()?.name?.charAt(0) ?? '?'} - -
+ {ws()?.name ?? '...'} @@ -192,22 +178,12 @@ export function WorkspaceSwitcher(): JSXElement { class="w-full flex items-center gap-2 text-sm px-2 py-1.5 rounded-lg hover:bg-base-200 transition-colors" onClick={() => handleSwitch(w)} > -
- - {w.name.charAt(0)} - -
+ {w.name} )} diff --git a/ui/src/components/auth-icons.tsx b/ui/src/components/auth-icons.tsx new file mode 100644 index 0000000..abfb2d3 --- /dev/null +++ b/ui/src/components/auth-icons.tsx @@ -0,0 +1,89 @@ +import { JSXElement, splitProps } from 'solid-js' +import type { JSX } from 'solid-js' + +interface IconProps { + size?: number + strokeWidth?: number + class?: string +} + +type SvgAttrs = JSX.SvgSVGAttributes + +function baseSvgAttrs(props: IconProps, defaultStroke = 2): SvgAttrs { + const [, rest] = splitProps(props, ['size', 'strokeWidth']) + return { + get width() { + return props.size ?? 15 + }, + get height() { + return props.size ?? 15 + }, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + get ['stroke-width']() { + return props.strokeWidth ?? defaultStroke + }, + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + ...rest, + } +} + +export function EnvelopeIcon(props: IconProps = {}): JSXElement { + return ( + + + + + ) +} + +export function LockIcon(props: IconProps = {}): JSXElement { + return ( + + + + + ) +} + +export function UserIcon(props: IconProps = {}): JSXElement { + return ( + + + + + ) +} + +export function ShieldIcon(props: IconProps = {}): JSXElement { + return ( + + + + ) +} + +export function ArrowRightIcon(props: IconProps = {}): JSXElement { + return ( + + + + + ) +} + +export function AppLogo(props: { size?: number } = {}): JSXElement { + return ( + + + + + ) +} diff --git a/ui/src/pages/AcceptInvitation.tsx b/ui/src/pages/AcceptInvitation.tsx index fb4ad00..025274f 100644 --- a/ui/src/pages/AcceptInvitation.tsx +++ b/ui/src/pages/AcceptInvitation.tsx @@ -6,6 +6,7 @@ import { useUser } from '../context/UserProvider' import { useWorkspace } from '../context/WorkspaceProvider' import { acceptInvitation } from '../api' import { Button } from '../components/Button' +import { Spinner } from '../components/Spinner' type AcceptState = 'idle' | 'loading' | 'error' @@ -60,7 +61,7 @@ export function AcceptInvitationPage(): JSXElement {
- +
diff --git a/ui/src/pages/Login.tsx b/ui/src/pages/Login.tsx index a838c7f..1087062 100644 --- a/ui/src/pages/Login.tsx +++ b/ui/src/pages/Login.tsx @@ -13,6 +13,15 @@ import { useUser } from '../context/UserProvider' import { useLocale } from '../context/LocaleProvider' import { Alert } from '../components/Alert' import { Button } from '../components/Button' +import { AuthFormField } from '../components/AuthFormField' +import { MarketingAuthLayout } from '../components/MarketingAuthLayout' +import { FullScreenSpinner } from '../components/Spinner' +import { + ArrowRightIcon, + EnvelopeIcon, + LockIcon, + ShieldIcon, +} from '../components/auth-icons' import { createFormState } from '../form_helpers' export function LoginPage(): JSXElement { @@ -23,9 +32,7 @@ export function LoginPage(): JSXElement { return ( -
- -
+
@@ -88,286 +95,169 @@ function LoginForm(): JSXElement { : '/register' return ( -
-
- - + {t('dont_have_an_account')} {t('sign_up')} → -
- -
-
-
-
-

- {t('login_headline_line1')} -
- {t('login_headline_line2')}{' '} - {t('login_headline_line2_accent')} -

-

{t('login_subtitle')}

-
-
- -
- -

{t('login_form_title')}

-

{t('login_form_subtitle')}

- - -
- - {(field, props) => ( -
- -
- - - - - - - -
- -
{field.error}
-
-
- )} -
- - - {(field, props) => ( -
- -
- - - - - - - -
- -
{field.error}
-
-
- )} -
+ } + headline={ + <> + {t('login_headline_line1')} +
+ {t('login_headline_line2')}{' '} + {t('login_headline_line2_accent')} + + } + subtitle={t('login_subtitle')} + formPanel={ + <> + +

{t('login_form_title')}

+

{t('login_form_subtitle')}

- + +
+ + {(field, props) => ( + } + error={field.error} + inputProps={{ + ...props, + 'data-cy': 'login-email', + type: 'email', + value: field.value || '', + placeholder: t('email_placeholder'), + autocomplete: 'email', + }} + /> + )} + - - + {(field, props) => ( + } + error={field.error} + inputProps={{ + ...props, + 'data-cy': 'login-password', + type: 'password', + value: field.value || '', + placeholder: t('password'), + autocomplete: 'current-password', + }} /> - + )} + -
-
+ - -

{t('login_form_title')}

-

- {t( - 'enter_the_6_digit_code_generated_by_your_authenticator_app' - )} -

+ +
- -
- - {(field, props) => ( -
-
- - - - - - -
- -
{field.error}
-
-
- )} -
+ +

{t('login_form_title')}

+

+ {t('enter_the_6_digit_code_generated_by_your_authenticator_app')} +

- - - + +
+ + {(field, props) => ( + } + error={field.error} + inputProps={{ + ...props, + type: 'text', + value: field.value || '', + placeholder: '000000', + autocomplete: 'one-time-code', + }} + /> + )} + -
-
-
-
-
-
-
+
+ + + + } + /> ) } diff --git a/ui/src/pages/Register.tsx b/ui/src/pages/Register.tsx index adf9ac2..1d44508 100644 --- a/ui/src/pages/Register.tsx +++ b/ui/src/pages/Register.tsx @@ -8,7 +8,7 @@ import { createResource, } from 'solid-js' import { Navigate, useNavigate, useSearchParams, A } from '@solidjs/router' -import { minLength, pattern, email, required } from '@modular-forms/solid' +import { email, required } from '@modular-forms/solid' import { register, RegisterUserData, lookupInvitation } from '../api' import { User, UserAttributes } from '../models/User' @@ -17,8 +17,18 @@ import { useLocale } from '../context/LocaleProvider' import { useWorkspace } from '../context/WorkspaceProvider' import { Alert } from '../components/Alert' import { Button } from '../components/Button' +import { AuthFormField } from '../components/AuthFormField' +import { MarketingAuthLayout } from '../components/MarketingAuthLayout' +import { FullScreenSpinner } from '../components/Spinner' +import { + AppLogo, + ArrowRightIcon, + EnvelopeIcon, + LockIcon, + UserIcon, +} from '../components/auth-icons' import { createFormState } from '../form_helpers' -import { mustMatch } from '../validators' +import { mustMatch, passwordRules } from '../validators' function PasswordStrengthBar(props: { value: string | undefined }) { const score = createMemo(() => { @@ -62,9 +72,7 @@ export function RegisterPage(): JSXElement { return ( -
- -
+
@@ -76,6 +84,39 @@ export function RegisterPage(): JSXElement { ) } +function InviteBadge(props: { workspaceName: string }): JSXElement { + const { t } = useLocale() + return ( +
+
+ + + + + + +
+
+ + {t('invite_badge_label')} + + + {props.workspaceName} + +
+
+ ) +} + function RegisterForm(): JSXElement { const { t } = useLocale() const { setUser } = useUser() @@ -124,47 +165,21 @@ function RegisterForm(): JSXElement { return '/login' } - return ( -
-
- + const inviteLogo = + const defaultLogo = ( + <> +
+ +
+ {t('my_solid_app')} + + ) + + return ( + } > -
-
+ + + } + headline={ + + {t('register_headline_line1')} +
+ {t('register_headline_line2')}{' '} + {t('register_headline_line2_accent')} + + } + > + {t('invite_headline_line1')} +
+ {t('invite_headline_line2')}{' '} + {t('invite_headline_line2_accent')} +
+ } + subtitle={ + + {t('invite_subtitle')} + + } + formPanel={ + <> +

+ + {t('invite_join_workspace')} + +

+ + +
- - - - + + -
-
- - {t('invite_badge_label')} - - - {inviteInfo()!.workspace_name} + + {t('register_free_start')} —{' '} + {t('register_no_credit_card')}
-
- -
- -
-
-
-
-

- - {t('register_headline_line1')} -
- {t('register_headline_line2')}{' '} - {t('register_headline_line2_accent')} - - } - > - {t('invite_headline_line1')} -
- {t('invite_headline_line2')}{' '} - {t('invite_headline_line2_accent')} -
-

-

- - {t('invite_subtitle')} - -

-
-
- -
-

- - {t('invite_join_workspace')} - -

- - -
- - - - - - {t('register_free_start')} —{' '} - {t('register_no_credit_card')} - -
-
- -
-
- - {(field, props) => ( -
- -
- - - - - - - -
- -
{field.error}
-
-
- )} -
+ - - {(field, props) => ( -
- -
- - - - - - - -
- -
{field.error}
-
-
- )} -
+ +
+ + {(field, props) => ( + } + error={field.error} + inputProps={{ + ...props, + id: 'register-name', + 'data-cy': 'register-name', + type: 'text', + value: field.value || '', + placeholder: t('name_placeholder'), + autocomplete: 'given-name', + }} + /> + )} + - - {(field, props) => ( -
- -
- - - - - - - -
- - -
{field.error}
-
-
- )} -
+ + {(field, props) => ( + } + error={field.error} + inputProps={{ + ...props, + id: 'register-email', + 'data-cy': 'register-email', + type: 'email', + value: isInvite() + ? inviteInfo()!.email + : field.value || '', + readonly: isInvite(), + placeholder: t('email_placeholder'), + autocomplete: 'email', + }} + /> + )} + - - {(field, props) => ( -
- -
- - - - - - - -
- -
{field.error}
-
-
- )} -
+ + {(field, props) => ( + <> + } + error={field.error} + inputProps={{ + ...props, + id: 'register-password', + 'data-cy': 'register-password', + type: 'password', + value: field.value || '', + placeholder: t('register_password_placeholder'), + autocomplete: 'new-password', + }} + /> + + + )} + - - + {(field, props) => ( + } + error={field.error} + inputProps={{ + ...props, + id: 'register-check-password', + 'data-cy': 'register-check-password', + type: 'password', + value: field.value || '', + placeholder: t('register_confirm_password_placeholder'), + autocomplete: 'new-password', + }} /> - + )} + -
- +
-
-
+ + } + /> ) } diff --git a/ui/src/pages/ResetPassword.tsx b/ui/src/pages/ResetPassword.tsx index 7f0fef6..b1b5ebe 100644 --- a/ui/src/pages/ResetPassword.tsx +++ b/ui/src/pages/ResetPassword.tsx @@ -1,6 +1,6 @@ import { JSXElement, Show } from 'solid-js' import { A, useSearchParams } from '@solidjs/router' -import { required, minLength, pattern } from '@modular-forms/solid' +import { required } from '@modular-forms/solid' import { AuthCard } from '../components/AuthCard' import { TextInput } from '../components/TextInput' @@ -10,7 +10,7 @@ import { useLocale } from '../context/LocaleProvider' import { resetPassword, ResetPasswordData } from '../api' import { getSingleParam } from './SearchParams' import { createFormState } from '../form_helpers' -import { mustMatch } from '../validators' +import { mustMatch, passwordRules } from '../validators' export function ResetPasswordPage(): JSXElement { const { t } = useLocale() @@ -71,10 +71,7 @@ export function ResetPasswordPage(): JSXElement { name="newPassword" validate={[ required(t('please_enter_a_new_password')), - minLength(8, t('your_password_must_have_8_characters_or_more')), - pattern(/[A-Z]/, t('your_password_must_have_1_uppercase_letter')), - pattern(/[a-z]/, t('your_password_must_have_1_lowercase_letter')), - pattern(/[0-9]/, t('your_password_must_have_1_digit')), + ...passwordRules(t, { requireSpecialChar: false }), ]} > {(field, props) => ( diff --git a/ui/src/pages/VerifyEmail.tsx b/ui/src/pages/VerifyEmail.tsx index 05db1e6..2ab789b 100644 --- a/ui/src/pages/VerifyEmail.tsx +++ b/ui/src/pages/VerifyEmail.tsx @@ -3,6 +3,7 @@ import { A, useSearchParams } from '@solidjs/router' import { Alert } from '../components/Alert' import { AuthCard } from '../components/AuthCard' +import { CenteredSpinner } from '../components/Spinner' import { useUser } from '../context/UserProvider' import { useLocale } from '../context/LocaleProvider' import { getErrorMessage, verifyEmail } from '../api' @@ -40,9 +41,7 @@ export function VerifyEmailPage(): JSXElement { return ( -
- -
+
diff --git a/ui/src/pages/admin_page/UsersAdmin.tsx b/ui/src/pages/admin_page/UsersAdmin.tsx index 867607f..541dd93 100644 --- a/ui/src/pages/admin_page/UsersAdmin.tsx +++ b/ui/src/pages/admin_page/UsersAdmin.tsx @@ -3,25 +3,22 @@ import { createSignal, createResource, JSXElement, Show, For } from 'solid-js' import { UserAttributes } from '../../models/User' import { Alert } from '../../components/Alert' import { Pagination } from '../../components/Pagination' -import { - createUser, - getUsers, - deleteUser, - CreateUserData, - DeleteUserData, -} from '../../api' +import { createUser, getUsers, deleteUser, CreateUserData } from '../../api' import { BooleanInput } from '../../components/BooleanInput' import { TextInput } from '../../components/TextInput' import { createModalState, Modal, ModalBaseProps } from '../../components/Modal' +import { ConfirmModal } from '../../components/ConfirmModal' +import { Spinner } from '../../components/Spinner' import { useLocale } from '../../context/LocaleProvider' -import { pattern, email, minLength, required } from '@modular-forms/solid' +import { email, required } from '@modular-forms/solid' import { Table, TableRow } from '../../components/Table' import { Tooltip } from '../../components/Tooltip' import { Button, IconButton } from '../../components/Button' import { createFormState } from '../../form_helpers' +import { passwordRules } from '../../validators' export function UsersAdmin(): JSXElement { const { t } = useLocale() @@ -140,7 +137,7 @@ export function UsersAdmin(): JSXElement {
- +
@@ -177,55 +174,39 @@ function DeleteUserModal( } & ModalBaseProps ): JSXElement { const { t } = useLocale() - const user = () => props.user - - const { - state, - onSubmit, - components: { Form }, - } = createFormState({ - action: () => deleteUser({ userID: user()?.id ?? 0 }), - onFinish: () => { - props.onDelete() - props.onClose() - }, - }) + const [submitting, setSubmitting] = createSignal(false) + const [error, setError] = createSignal(null) + + const handleConfirm = async () => { + const id = props.user?.id + if (!id) return + setError(null) + setSubmitting(true) + const response = await deleteUser({ userID: id }) + setSubmitting(false) + if (response.status !== 200) { + setError(t('an_unknown_error_occurred')) + return + } + props.onDelete() + props.onClose() + } return ( -

{t('delete_user_confirmation')}

{props.user?.email}

- - - - - -
- -
-
+ ) } @@ -283,11 +264,7 @@ function CreateUserModal(props: CreateUserModalProps): JSXElement { name="password" validate={[ required(t('please_enter_a_password')), - minLength(8, t('your_password_must_have_8_characters_or_more')), - pattern(/[A-Z]/, t('your_password_must_have_1_uppercase_letter')), - pattern(/[a-z]/, t('your_password_must_have_1_lowercase_letter')), - pattern(/[0-9]/, t('your_password_must_have_1_digit')), - pattern(/[\W]/, t('your_password_must_have_1_special_character')), + ...passwordRules(t), ]} > {(field, props) => ( diff --git a/ui/src/pages/billing/BillingCheckoutPage.tsx b/ui/src/pages/billing/BillingCheckoutPage.tsx index a8bc598..23c0474 100644 --- a/ui/src/pages/billing/BillingCheckoutPage.tsx +++ b/ui/src/pages/billing/BillingCheckoutPage.tsx @@ -15,6 +15,7 @@ import { } from '../../api' import { useLocale } from '../../context/LocaleProvider' import { useWorkspace } from '../../context/WorkspaceProvider' +import { StatusBanner } from '../../components/StatusBanner' type CheckoutStep = 1 | 2 | 3 @@ -237,10 +238,7 @@ export function BillingCheckoutPage(): JSXElement {

-
- - {checkoutError()} -
+
@@ -281,10 +282,11 @@ export function BillingTabContent(props: {

-
- - {checkoutError()} -
+
@@ -304,34 +306,25 @@ export function BillingTabContent(props: {

{t('billing_pro_plan')}

- - - Pro - +

€{seatPrice().toFixed(2)} {t('billing_per_user_month')}

-
-

{b().member_count}

-

- {t('members')} -

-
-
-

€{total()}

-

- {t('billing_per_month')} -

-
-
-

{nextInvoiceLabel()}

-

- {t('billing_next_invoice')} -

-
+ + +
@@ -448,34 +441,22 @@ export function BillingTabContent(props: {

{t('billing_pro_plan')}

- - - Pro - +

{t('billing_exempt_description')}

-
-

{b().member_count}

-

- {t('members')} -

-
-
-

-

- {t('billing_per_month')} -

-
-
-

-

- {t('billing_next_invoice')} -

-
+ + +
@@ -496,29 +477,20 @@ export function BillingTabContent(props: { - {/* Cancel subscription modal */} - setCancelModalOpen(false)} title={t('cancel_subscription')} - > -

- {t('cancel_subscription_confirmation')} -

-
-
-
+ message={ + + {t('cancel_subscription_confirmation')} + + } + confirmLabel={t('yes_cancel_subscription')} + confirmColor="error" + isLoading={cancelling()} + onConfirm={handleCancelConfirm} + />
) } diff --git a/ui/src/pages/user_account_page/modals/ChangePassword.tsx b/ui/src/pages/user_account_page/modals/ChangePassword.tsx index 908f642..d39a4f8 100644 --- a/ui/src/pages/user_account_page/modals/ChangePassword.tsx +++ b/ui/src/pages/user_account_page/modals/ChangePassword.tsx @@ -4,14 +4,14 @@ import { useUser } from '../../../context/UserProvider' import { useLocale } from '../../../context/LocaleProvider' import { changePassword, ChangePasswordData } from '../../../api' -import { minLength, pattern, required } from '@modular-forms/solid' +import { required } from '@modular-forms/solid' import { Alert } from '../../../components/Alert' import { TextInput } from '../../../components/TextInput' import { Modal, ModalBaseProps } from '../../../components/Modal' import { Button } from '../../../components/Button' import { createFormState } from '../../../form_helpers' -import { mustMatch } from '../../../validators' +import { mustMatch, passwordRules } from '../../../validators' export function ChangePasswordModal(props: ModalBaseProps): JSXElement { const { t } = useLocale() @@ -65,11 +65,7 @@ export function ChangePasswordModal(props: ModalBaseProps): JSXElement { name="newPassword" validate={[ required(t('please_enter_a_new_password')), - minLength(8, t('your_password_must_have_8_characters_or_more')), - pattern(/[A-Z]/, t('your_password_must_have_1_uppercase_letter')), - pattern(/[a-z]/, t('your_password_must_have_1_lowercase_letter')), - pattern(/[0-9]/, t('your_password_must_have_1_digit')), - pattern(/[\W]/, t('your_password_must_have_1_special_character')), + ...passwordRules(t), ]} > {(field, props) => ( diff --git a/ui/src/pages/user_account_page/modals/DeleteAccount.tsx b/ui/src/pages/user_account_page/modals/DeleteAccount.tsx index 7cf6102..d9d97f8 100644 --- a/ui/src/pages/user_account_page/modals/DeleteAccount.tsx +++ b/ui/src/pages/user_account_page/modals/DeleteAccount.tsx @@ -1,63 +1,47 @@ -import { JSXElement, Show } from 'solid-js' +import { JSXElement, createSignal } from 'solid-js' import { useNavigate } from '@solidjs/router' + import { useUser } from '../../../context/UserProvider' import { useLocale } from '../../../context/LocaleProvider' - import { deleteAccount } from '../../../api' -import { Modal, ModalBaseProps } from '../../../components/Modal' -import { Alert } from '../../../components/Alert' -import { Button } from '../../../components/Button' -import { createFormState } from '../../../form_helpers' +import { ConfirmModal } from '../../../components/ConfirmModal' +import { ModalBaseProps } from '../../../components/Modal' export function DeleteAccountModal(props: ModalBaseProps): JSXElement { const { t } = useLocale() const { setUser } = useUser() - const navigate = useNavigate() - const { - state, - onSubmit, - components: { Form }, - } = createFormState({ - action: deleteAccount, - onFinish: () => { - setUser(null) - navigate('/home') - }, - }) + const [submitting, setSubmitting] = createSignal(false) + const [error, setError] = createSignal(null) + + const handleConfirm = async () => { + setError(null) + setSubmitting(true) + const response = await deleteAccount() + setSubmitting(false) + if (response.status !== 200) { + setError(t('an_unknown_error_occurred')) + return + } + setUser(null) + navigate('/home') + } return ( -

{t('are_you_sure_you_want_to_delete_your_account')}

{t('this_action_cannot_be_undone')}

- - - - - -
- -
-
+ ) } diff --git a/ui/src/pages/workspace_modals/WorkspaceSettingsModal.tsx b/ui/src/pages/workspace_modals/WorkspaceSettingsModal.tsx index d622bee..1d0ae8e 100644 --- a/ui/src/pages/workspace_modals/WorkspaceSettingsModal.tsx +++ b/ui/src/pages/workspace_modals/WorkspaceSettingsModal.tsx @@ -16,7 +16,9 @@ import clsx from 'clsx' import { DeleteWorkspaceModal } from './DeleteWorkspaceModal' import { Alert } from '../../components/Alert' import { Button } from '../../components/Button' +import { ConfirmModal } from '../../components/ConfirmModal' import { ModalBaseProps } from '../../components/Modal' +import { PlanBadge } from '../../components/PlanBadge' import { TextInput } from '../../components/TextInput' import { TranslationKey, useLocale } from '../../context/LocaleProvider' import { useUser } from '../../context/UserProvider' @@ -524,17 +526,7 @@ export function WorkspaceSettingsModal(props: Props): JSXElement { '...'}
- - - - - Pro - +
@@ -1057,101 +1049,49 @@ export function WorkspaceSettingsModal(props: Props): JSXElement {