From 66ad4f8753e828dfb99af2601c12f88b09103948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 27 Mar 2024 11:31:54 +0000 Subject: [PATCH] Multitenancy support --- src/api/hooks/admin/index.ts | 4 + src/api/hooks/admin/useCreateDomain.ts | 23 +++ src/api/hooks/admin/useDeleteDomain.ts | 26 ++++ src/api/hooks/admin/useDomains.ts | 25 +++ src/api/hooks/admin/useUpdateDomain.ts | 20 +++ src/components/ui/hstack/hstack.tsx | 8 +- src/components/ui/stack/stack.tsx | 2 - src/entity-store/entities.ts | 2 + src/features/admin/domains.tsx | 144 ++++++++++++++++++ src/features/admin/tabs/dashboard.tsx | 9 +- .../components/registration-form.tsx | 35 ++++- src/features/ui/components/modal-root.tsx | 2 + .../components/modals/edit-domain-modal.tsx | 101 ++++++++++++ src/features/ui/index.tsx | 4 +- src/features/ui/util/async-components.ts | 2 + src/locales/en.json | 25 +++ src/schemas/domain.ts | 13 ++ src/schemas/index.ts | 1 + src/schemas/instance.ts | 12 ++ src/utils/features.ts | 20 +++ 20 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 src/api/hooks/admin/useCreateDomain.ts create mode 100644 src/api/hooks/admin/useDeleteDomain.ts create mode 100644 src/api/hooks/admin/useDomains.ts create mode 100644 src/api/hooks/admin/useUpdateDomain.ts create mode 100644 src/features/admin/domains.tsx create mode 100644 src/features/ui/components/modals/edit-domain-modal.tsx create mode 100644 src/schemas/domain.ts diff --git a/src/api/hooks/admin/index.ts b/src/api/hooks/admin/index.ts index ef4dc082d..23f815c8a 100644 --- a/src/api/hooks/admin/index.ts +++ b/src/api/hooks/admin/index.ts @@ -1,2 +1,6 @@ +export { useCreateDomain, type CreateDomainParams } from './useCreateDomain'; +export { useDeleteDomain } from './useDeleteDomain'; +export { useDomains } from './useDomains'; export { useSuggest } from './useSuggest'; +export { useUpdateDomain } from './useUpdateDomain'; export { useVerify } from './useVerify'; \ No newline at end of file diff --git a/src/api/hooks/admin/useCreateDomain.ts b/src/api/hooks/admin/useCreateDomain.ts new file mode 100644 index 000000000..5897fda19 --- /dev/null +++ b/src/api/hooks/admin/useCreateDomain.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useCreateEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { domainSchema } from 'soapbox/schemas'; + +interface CreateDomainParams { + domain: string; + public: boolean; +} + +const useCreateDomain = () => { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity([Entities.DOMAINS], (params: CreateDomainParams) => + api.post('/api/v1/pleroma/admin/domains', params), { schema: domainSchema }); + + return { + createDomain: createEntity, + ...rest, + }; +}; + +export { useCreateDomain, type CreateDomainParams }; diff --git a/src/api/hooks/admin/useDeleteDomain.ts b/src/api/hooks/admin/useDeleteDomain.ts new file mode 100644 index 000000000..3975209e0 --- /dev/null +++ b/src/api/hooks/admin/useDeleteDomain.ts @@ -0,0 +1,26 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useDeleteEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; + +interface DeleteDomainParams { + domain: string; + public: boolean; +} + +const useDeleteDomain = () => { + const api = useApi(); + + const { deleteEntity, ...rest } = useDeleteEntity(Entities.DOMAINS, (id: string) => + api.delete(`/api/v1/pleroma/admin/domains/${id}`, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + })); + + return { + mutate: deleteEntity, + ...rest, + }; +}; + +export { useDeleteDomain, type DeleteDomainParams }; diff --git a/src/api/hooks/admin/useDomains.ts b/src/api/hooks/admin/useDomains.ts new file mode 100644 index 000000000..998edca46 --- /dev/null +++ b/src/api/hooks/admin/useDomains.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; +import { domainSchema, type Domain } from 'soapbox/schemas'; + +const useDomains = () => { + const api = useApi(); + + const getDomains = async () => { + const { data } = await api.get('/api/v1/pleroma/admin/domains'); + + const normalizedData = data.map((domain) => domainSchema.parse(domain)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['domains'], + queryFn: getDomains, + placeholderData: [], + }); + + return result; +}; + +export { useDomains }; diff --git a/src/api/hooks/admin/useUpdateDomain.ts b/src/api/hooks/admin/useUpdateDomain.ts new file mode 100644 index 000000000..8ca27ba97 --- /dev/null +++ b/src/api/hooks/admin/useUpdateDomain.ts @@ -0,0 +1,20 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useCreateEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { domainSchema } from 'soapbox/schemas'; + +import type { CreateDomainParams } from './useCreateDomain'; + +const useUpdateDomain = (id: string) => { + const api = useApi(); + + const { createEntity, ...rest } = useCreateEntity([Entities.DOMAINS], (params: Omit) => + api.patch(`/api/v1/pleroma/admin/domains/${id}`, params), { schema: domainSchema }); + + return { + updateDomain: createEntity, + ...rest, + }; +}; + +export { useUpdateDomain }; diff --git a/src/components/ui/hstack/hstack.tsx b/src/components/ui/hstack/hstack.tsx index d43a328e0..eb435cbae 100644 --- a/src/components/ui/hstack/hstack.tsx +++ b/src/components/ui/hstack/hstack.tsx @@ -31,13 +31,9 @@ const spaces = { 8: 'space-x-8', }; -interface IHStack extends Pick, 'onClick'> { +interface IHStack extends Pick, 'children' | 'className' | 'onClick' | 'style' | 'title'> { /** Vertical alignment of children. */ alignItems?: keyof typeof alignItemsOptions; - /** Extra class names on the
element. */ - className?: string; - /** Children */ - children?: React.ReactNode; /** Horizontal alignment of children. */ justifyContent?: keyof typeof justifyContentOptions; /** Size of the gap between elements. */ @@ -46,8 +42,6 @@ interface IHStack extends Pick, 'onClick'> grow?: boolean; /** HTML element to use for container. */ element?: keyof JSX.IntrinsicElements; - /** Extra CSS styles for the
*/ - style?: React.CSSProperties; /** Whether to let the flexbox wrap onto multiple lines. */ wrap?: boolean; } diff --git a/src/components/ui/stack/stack.tsx b/src/components/ui/stack/stack.tsx index 3a27daad7..8a4370b04 100644 --- a/src/components/ui/stack/stack.tsx +++ b/src/components/ui/stack/stack.tsx @@ -32,8 +32,6 @@ const alignItemsOptions = { interface IStack extends React.HTMLAttributes { /** Horizontal alignment of children. */ alignItems?: keyof typeof alignItemsOptions; - /** Extra class names on the element. */ - className?: string; /** Vertical alignment of children. */ justifyContent?: keyof typeof justifyContentOptions; /** Size of the gap between elements. */ diff --git a/src/entity-store/entities.ts b/src/entity-store/entities.ts index d5c308446..97ef4d9ac 100644 --- a/src/entity-store/entities.ts +++ b/src/entity-store/entities.ts @@ -3,6 +3,7 @@ import type * as Schemas from 'soapbox/schemas'; enum Entities { ACCOUNTS = 'Accounts', BOOKMARK_FOLDERS = 'BookmarkFolders', + DOMAINS = 'Domains', GROUPS = 'Groups', GROUP_MEMBERSHIPS = 'GroupMemberships', GROUP_MUTES = 'GroupMutes', @@ -16,6 +17,7 @@ enum Entities { interface EntityTypes { [Entities.ACCOUNTS]: Schemas.Account; [Entities.BOOKMARK_FOLDERS]: Schemas.BookmarkFolder; + [Entities.DOMAINS]: Schemas.Domain; [Entities.GROUPS]: Schemas.Group; [Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember; [Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship; diff --git a/src/features/admin/domains.tsx b/src/features/admin/domains.tsx new file mode 100644 index 000000000..024cf750c --- /dev/null +++ b/src/features/admin/domains.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { openModal } from 'soapbox/actions/modals'; +import { useDeleteDomain, useDomains } from 'soapbox/api/hooks/admin'; +import { dateFormatOptions } from 'soapbox/components/relative-timestamp'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; +import toast from 'soapbox/toast'; + +import Indicator from '../developers/components/indicator'; + +import type { Domain as DomainEntity } from 'soapbox/schemas'; + +const messages = defineMessages({ + heading: { id: 'column.admin.domains', defaultMessage: 'Domains' }, + deleteConfirm: { id: 'confirmations.admin.delete_domain.confirm', defaultMessage: 'Delete' }, + deleteHeading: { id: 'confirmations.admin.delete_domain.heading', defaultMessage: 'Delete domain' }, + deleteMessage: { id: 'confirmations.admin.delete_domain.message', defaultMessage: 'Are you sure you want to delete the domain?' }, + domainDeleteSuccess: { id: 'admin.edit_domain.deleted', defaultMessage: 'Domain deleted' }, + domainLastChecked: { id: 'admin.domains.resolve.last_checked', defaultMessage: 'Last checked: {date}' }, +}); + +interface IDomain { + domain: DomainEntity; +} + +const Domain: React.FC = ({ domain }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const { mutate: deleteDomain } = useDeleteDomain(); + const { refetch } = useDomains(); + + const handleEditDomain = (domain: DomainEntity) => () => { + dispatch(openModal('EDIT_DOMAIN', { domainId: domain.id })); + }; + + const handleDeleteDomain = (id: string) => () => { + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.deleteHeading), + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + deleteDomain(domain.id).then(() => { + toast.success(messages.domainDeleteSuccess); + refetch(); + }).catch(() => {}); + }, + })); + }; + + const domainState = domain.last_checked_at ? (domain.resolves ? 'active' : 'error') : 'pending'; + const domainStateLabel = { + active: , + error: , + pending: , + }[domainState]; + const domainStateTitle = domain.last_checked_at ? intl.formatMessage(messages.domainLastChecked, { + date: intl.formatDate(domain.last_checked_at, dateFormatOptions), + }) : undefined; + + return ( +
+ + + + + + + {' '} + {domain.domain} + + + {domain.public ? ( + + ) : ( + + )} + + + + + {domainStateLabel} + + + + + + + + +
+ ); +}; + +const Domains: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const { data: domains, isFetching } = useDomains(); + + const handleCreateDomain = () => { + dispatch(openModal('EDIT_DOMAIN')); + }; + + const emptyMessage = ; + + return ( + + + + {domains && ( + + {domains.map((domain) => ( + + ))} + + )} + + + ); +}; + +export default Domains; diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index 8808b9632..d608eab89 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -93,12 +93,19 @@ const Dashboard: React.FC = () => { label={} /> - {features.announcements && ( + {features.adminAnnouncements && ( } /> )} + + {features.domains && ( + } + /> + )} {account.admin && ( diff --git a/src/features/auth-login/components/registration-form.tsx b/src/features/auth-login/components/registration-form.tsx index 53cc8171a..92c0ff52a 100644 --- a/src/features/auth-login/components/registration-form.tsx +++ b/src/features/auth-login/components/registration-form.tsx @@ -10,7 +10,7 @@ import { accountLookup } from 'soapbox/actions/accounts'; import { register, verifyCredentials } from 'soapbox/actions/auth'; import { openModal } from 'soapbox/actions/modals'; import BirthdayInput from 'soapbox/components/birthday-input'; -import { Checkbox, Form, FormGroup, FormActions, Button, Input, Textarea } from 'soapbox/components/ui'; +import { Checkbox, Form, FormGroup, FormActions, Button, Input, Textarea, Select } from 'soapbox/components/ui'; import CaptchaField from 'soapbox/features/auth-login/components/captcha'; import { useAppDispatch, useSettings, useFeatures, useInstance } from 'soapbox/hooks'; @@ -50,6 +50,7 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { const supportsEmailList = features.emailList; const supportsAccountLookup = features.accountLookup; const birthdayRequired = instance.pleroma.metadata.birthday_required; + const domains = instance.pleroma.metadata.multitenancy.enabled ? instance.pleroma.metadata.multitenancy.domains!.filter((domain) => domain.public) : undefined; const [captchaLoading, setCaptchaLoading] = useState(true); const [submissionLoading, setSubmissionLoading] = useState(false); @@ -80,7 +81,19 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { setUsernameUnavailable(false); source.current.cancel(); - usernameAvailable(e.target.value); + const domain = params.get('domain'); + usernameAvailable(e.target.value, domain ? domains!.find(({ id }) => id === domain)?.domain : undefined); + }; + + const onDomainChange: React.ChangeEventHandler = e => { + updateParams({ domain: e.target.value || null }); + setUsernameUnavailable(false); + source.current.cancel(); + + const username = params.get('username'); + if (username) { + usernameAvailable(username, domains!.find(({ id }) => id === e.target.value)?.domain); + } }; const onCheckboxChange: React.ChangeEventHandler = e => { @@ -155,12 +168,12 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { return params.get('password', '') === passwordConfirmation; }; - const usernameAvailable = useCallback(debounce(username => { + const usernameAvailable = useCallback(debounce((username, domain?: string) => { if (!supportsAccountLookup) return; const source = refreshCancelToken(); - dispatch(accountLookup(username, source.token)) + dispatch(accountLookup(`${username}${domain ? `@${domain}` : ''}`, source.token)) .then(account => { setUsernameUnavailable(!!account); }) @@ -244,6 +257,20 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { /> + {domains && ( + + + + )} + + {!features.nostrSignup && ( > = { 'DISLIKES': DislikesModal, 'EDIT_ANNOUNCEMENT': EditAnnouncementModal, 'EDIT_BOOKMARK_FOLDER': EditBookmarkFolderModal, + 'EDIT_DOMAIN': EditDomainModal, 'EDIT_FEDERATION': EditFederationModal, 'EMBED': EmbedModal, 'EVENT_MAP': EventMapModal, diff --git a/src/features/ui/components/modals/edit-domain-modal.tsx b/src/features/ui/components/modals/edit-domain-modal.tsx new file mode 100644 index 000000000..bcbca269f --- /dev/null +++ b/src/features/ui/components/modals/edit-domain-modal.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { closeModal } from 'soapbox/actions/modals'; +import { useCreateDomain, useDomains, useUpdateDomain } from 'soapbox/api/hooks/admin'; +import { Form, FormGroup, HStack, Input, Modal, Stack, Text, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; +import { Domain } from 'soapbox/schemas'; +import toast from 'soapbox/toast'; + +const messages = defineMessages({ + save: { id: 'admin.edit_domain.save', defaultMessage: 'Save' }, + domainPlaceholder: { id: 'admin.edit_domain.fields.domain_placeholder', defaultMessage: 'Identity domain name' }, + domainCreateSuccess: { id: 'admin.edit_domain.created', defaultMessage: 'Domain created' }, + domainUpdateSuccess: { id: 'admin.edit_domain.updated', defaultMessage: 'Domain edited' }, +}); + +interface IEditDomainModal { + onClose: (type?: string) => void; + domainId?: string; +} + +const EditDomainModal: React.FC = ({ onClose, domainId }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const { data: domains, refetch } = useDomains(); + const { createDomain, isSubmitting: isCreating } = useCreateDomain(); + const { updateDomain, isSubmitting: isUpdating } = useUpdateDomain(domainId!); + + const [domain] = useState(domainId ? domains!.find(({ id }) => domainId === id)! : null); + const [domainName, setDomainName] = useState(domain?.domain || ''); + const [isPublic, setPublic] = useState(domain?.public || false); + + const onClickClose = () => { + onClose('EDIT_DOMAIN'); + }; + + const handleSubmit = () => { + if (domainId) { + updateDomain({ + public: isPublic, + }).then(() => { + toast.success(messages.domainUpdateSuccess); + dispatch(closeModal('EDIT_DOMAIN')); + refetch(); + }).catch(() => {}); + } else { + createDomain({ + domain: domainName, + public: isPublic, + }).then(() => { + toast.success(messages.domainCreateSuccess); + dispatch(closeModal('EDIT_DOMAIN')); + refetch(); + }).catch(() => {}); + } + }; + + return ( + + : } + confirmationAction={handleSubmit} + confirmationText={intl.formatMessage(messages.save)} + confirmationDisabled={isCreating || isUpdating} + > +
+ } + > + setDomainName(target.value)} + disabled={!!domainId} + /> + + + setPublic(target.checked)} + /> + + + + + + + + + +
+
+ ); +}; + +export default EditDomainModal; diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 4d7fdf793..fdfb51b92 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -137,6 +137,7 @@ import { ExternalLogin, LandingTimeline, BookmarkFolders, + Domains, } from './util/async-components'; import GlobalHotkeys from './util/global-hotkeys'; import { WrappedRoute } from './util/react-router-helpers'; @@ -324,7 +325,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => - + {features.adminAnnouncements && } + {features.domains && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 4660eb047..54b7260d0 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -167,3 +167,5 @@ export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components export const BookmarkFolders = lazy(() => import('soapbox/features/bookmark-folders')); export const EditBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/edit-bookmark-folder-modal')); export const SelectBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/select-bookmark-folder-modal')); +export const Domains = lazy(() => import('soapbox/features/admin/domains')); +export const EditDomainModal = lazy(() => import('soapbox/features/ui/components/modals/edit-domain-modal')); diff --git a/src/locales/en.json b/src/locales/en.json index 684b4b65a..fd2e125e3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -105,6 +105,16 @@ "admin.dashcounters.user_count_label": "total users", "admin.dashwidgets.email_list_header": "Email list", "admin.dashwidgets.software_header": "Software", + "admin.domains.action": "Create domain", + "admin.domains.delete": "Delete", + "admin.domains.edit": "Edit", + "admin.domains.name": "Domain:", + "admin.domains.private": "Private", + "admin.domains.public": "Public", + "admin.domains.resolve.fail_label": "Not resolving", + "admin.domains.resolve.last_checked": "Last checked: {date}", + "admin.domains.resolve.pending_label": "Pending resolve check", + "admin.domains.resolve.success_label": "Resolves correctly", "admin.edit_announcement.created": "Announcement created", "admin.edit_announcement.deleted": "Announcement deleted", "admin.edit_announcement.fields.all_day_hint": "When checked, only the dates of the time range will be displayed", @@ -117,6 +127,14 @@ "admin.edit_announcement.fields.start_time_placeholder": "Announcement starts on:", "admin.edit_announcement.save": "Save", "admin.edit_announcement.updated": "Announcement edited", + "admin.edit_domain.created": "Domain created", + "admin.edit_domain.deleted": "Domain deleted", + "admin.edit_domain.fields.all_day_hint": "When checked, everyone can sign up for an username with this domain", + "admin.edit_domain.fields.domain_label": "Domain", + "admin.edit_domain.fields.domain_placeholder": "Identity domain name", + "admin.edit_domain.fields.public_label": "Public", + "admin.edit_domain.save": "Save", + "admin.edit_domain.updated": "Domain edited", "admin.latest_accounts_panel.more": "Click to see {count, plural, one {# account} other {# accounts}}", "admin.latest_accounts_panel.title": "Latest Accounts", "admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.", @@ -292,8 +310,11 @@ "column.admin.announcements": "Announcements", "column.admin.awaiting_approval": "Awaiting Approval", "column.admin.create_announcement": "Create announcement", + "column.admin.create_domain": "Create domaian", "column.admin.dashboard": "Dashboard", + "column.admin.domains": "Domains", "column.admin.edit_announcement": "Edit announcement", + "column.admin.edit_domain": "Edit domain", "column.admin.moderation_log": "Moderation Log", "column.admin.reports": "Reports", "column.admin.reports.menu.moderation_log": "Moderation Log", @@ -459,6 +480,9 @@ "confirmations.admin.delete_announcement.confirm": "Delete", "confirmations.admin.delete_announcement.heading": "Delete announcement", "confirmations.admin.delete_announcement.message": "Are you sure you want to delete the announcement?", + "confirmations.admin.delete_domain.confirm": "Delete", + "confirmations.admin.delete_domain.heading": "Delete domain", + "confirmations.admin.delete_domain.message": "Are you sure you want to delete the domain?", "confirmations.admin.delete_local_user.checkbox": "I understand that I am about to delete a local user.", "confirmations.admin.delete_status.confirm": "Delete post", "confirmations.admin.delete_status.heading": "Delete post", @@ -654,6 +678,7 @@ "empty_column.account_timeline": "No posts here!", "empty_column.account_unavailable": "Profile unavailable", "empty_column.admin.announcements": "There are no announcements yet.", + "empty_column.admin.domains": "There are no domains yet.", "empty_column.aliases": "You haven't created any account alias yet.", "empty_column.aliases.suggestions": "There are no account suggestions available for the provided term.", "empty_column.blocks": "You haven't blocked any users yet.", diff --git a/src/schemas/domain.ts b/src/schemas/domain.ts new file mode 100644 index 000000000..f3b057b86 --- /dev/null +++ b/src/schemas/domain.ts @@ -0,0 +1,13 @@ +import z from 'zod'; + +const domainSchema = z.object({ + id: z.coerce.string(), + domain: z.string().catch(''), + public: z.boolean().catch(false), + resolves: z.boolean().catch(false), + last_checked_at: z.string().datetime().catch(''), +}); + +type Domain = z.infer + +export { domainSchema, type Domain }; diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 6dc39e15a..336a628ee 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -4,6 +4,7 @@ export { bookmarkFolderSchema, type BookmarkFolder } from './bookmark-folder'; export { cardSchema, type Card } from './card'; export { chatMessageSchema, type ChatMessage } from './chat-message'; export { customEmojiSchema, type CustomEmoji } from './custom-emoji'; +export { domainSchema, type Domain } from './domain'; export { emojiReactionSchema, type EmojiReaction } from './emoji-reaction'; export { groupSchema, type Group } from './group'; export { groupMemberSchema, type GroupMember } from './group-member'; diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index ae167c974..081f2f493 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -98,6 +98,18 @@ const pleromaSchema = coerceObject({ value_length: z.number().nonnegative().catch(2047), }), migration_cooldown_period: z.number().optional().catch(undefined), + multitenancy: coerceObject({ + domains: z + .array( + z.object({ + domain: z.coerce.string(), + id: z.string(), + public: z.boolean().catch(false), + }), + ) + .optional(), + enabled: z.boolean().catch(false), + }), restrict_unauthenticated: coerceObject({ activities: coerceObject({ local: z.boolean().catch(false), diff --git a/src/utils/features.ts b/src/utils/features.ts index c2a3d19d0..37c7af53b 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -186,6 +186,17 @@ const getInstanceFeatures = (instance: Instance) => { */ accountWebsite: v.software === TRUTHSOCIAL, + /** + * Ability to manage announcements by admins. + * @see GET /api/v1/pleroma/admin/announcements + * @see GET /api/v1/pleroma/admin/announcements/:id + * @see POST /api/v1/pleroma/admin/announcements + * @see PATCH /api/v1/pleroma/admin/announcements/:id + * @see DELETE /api/v1/pleroma/admin/announcements/:id + * @see {@link https://docs.pleroma.social/backend/development/API/admin_api/#get-apiv1pleromaadminannouncements} + */ + adminAnnouncements: v.software === PLEROMA && gte(v.version, '2.2.49'), + /** * An additional moderator interface is available on the domain. * @see /pleroma/admin @@ -372,6 +383,15 @@ const getInstanceFeatures = (instance: Instance) => { */ dislikes: v.software === FRIENDICA && gte(v.version, '2023.3.0'), + /** + * Allow to register on a given domain + * @see GET /api/v1/pleroma/admin/domains + * @see POST /api/v1/pleroma/admin/domains + * @see PATCH /api/v1/pleroma/admin/domains/:id + * @see DELETE /api/v1/pleroma/admin/domains/:id + */ + domains: instance.pleroma.metadata.multitenancy.enabled, + /** * Ability to edit profile information. * @see PATCH /api/v1/accounts/update_credentials