Add for leave family
This commit is contained in:
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { AddFamilyMemberDto, CreateFamilyGroupDto, LeaveFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
import { AddFamilyMemberDto, CreateFamilyGroupDto, LeaveFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, TransferFamilyOwnershipDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('Семья')
|
||||
@ApiBearerAuth()
|
||||
@@ -108,6 +108,23 @@ export class FamilyController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/transfer-ownership')
|
||||
@ApiOperation({ summary: 'Передать управление семейной группой' })
|
||||
async transferOwnership(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: TransferFamilyOwnershipDto
|
||||
) {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.family.TransferFamilyOwnership({
|
||||
requesterId,
|
||||
groupId,
|
||||
newOwnerUserId: dto.newOwnerUserId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('groups/:groupId/invite-search')
|
||||
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
|
||||
async searchInviteUsers(
|
||||
|
||||
@@ -161,3 +161,10 @@ export class LeaveFamilyGroupDto {
|
||||
newOwnerUserId?: string;
|
||||
}
|
||||
|
||||
export class TransferFamilyOwnershipDto {
|
||||
@ApiProperty({ description: 'ID пользователя, которому передаётся управление семьёй' })
|
||||
@IsString({ message: 'ID нового главы семьи должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите нового главу семьи' })
|
||||
newOwnerUserId!: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#eceef4] bg-white">
|
||||
<div className="overflow-x-auto rounded-[24px] border border-[#eceef4] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function DocumentsPage() {
|
||||
|
||||
return (
|
||||
<IdShell active="/documents" wide>
|
||||
<h1 className="text-3xl font-medium tracking-tight">Документы</h1>
|
||||
<h1 className="text-2xl font-medium tracking-tight sm:text-3xl">Документы</h1>
|
||||
<p className="mt-1 text-sm text-[#667085]">Храните документы и заполняйте формы — ID подставит данные там, где вы разрешите.</p>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#fff4f4,#fff8ef)] p-5">
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function FamilyGroupPage({ params }: { params: Promise<{ groupId:
|
||||
const { groupId } = use(params);
|
||||
|
||||
return (
|
||||
<IdShell active="/family" wide>
|
||||
<IdShell active="/family" fullBleed>
|
||||
<FamilyGroupPageContent groupId={groupId} />
|
||||
</IdShell>
|
||||
);
|
||||
|
||||
@@ -65,12 +65,12 @@ export default function FamilyPage() {
|
||||
return (
|
||||
<IdShell active="/family" wide>
|
||||
<p className="text-sm text-[#667085]">Семья</p>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Семейный доступ</h1>
|
||||
<p className="mt-2 text-[#667085]">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
||||
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Семейный доступ</h1>
|
||||
<p className="mt-2 text-sm text-[#667085] sm:text-base">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
||||
|
||||
<div className="mt-8 flex gap-3">
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" />
|
||||
<Button onClick={() => void createGroup()} disabled={creating || !name.trim()}>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:mt-8 sm:flex-row">
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" className="min-w-0 flex-1" />
|
||||
<Button className="shrink-0" onClick={() => void createGroup()} disabled={creating || !name.trim()}>
|
||||
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,11 @@ body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.rdp-root {
|
||||
|
||||
@@ -335,7 +335,7 @@ export default function SecurityPage() {
|
||||
return (
|
||||
<IdShell active="/security">
|
||||
<p className="text-sm text-[#667085]">Безопасность</p>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Устройства</h1>
|
||||
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства</h1>
|
||||
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRightLeft,
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
@@ -95,6 +96,7 @@ import {
|
||||
setChatRoomPinned,
|
||||
setChatRoomMuted,
|
||||
toggleChatMessageReaction,
|
||||
transferFamilyOwnership,
|
||||
updateFamilyGroup,
|
||||
uploadMediaObject,
|
||||
voteChatPoll
|
||||
@@ -281,7 +283,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
|
||||
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
|
||||
const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
|
||||
const [transferOwnershipDialogOpen, setTransferOwnershipDialogOpen] = useState(false);
|
||||
const [leavingFamily, setLeavingFamily] = useState(false);
|
||||
const [transferringOwnership, setTransferringOwnership] = useState(false);
|
||||
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
|
||||
const [deletingFamily, setDeletingFamily] = useState(false);
|
||||
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
|
||||
@@ -396,6 +400,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
);
|
||||
const ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0);
|
||||
const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0);
|
||||
const mobileChatOpen = Boolean(activeRoomId);
|
||||
const isChatCreator = activeRoom?.createdById === user?.id;
|
||||
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
|
||||
const canManageActiveBot = Boolean(
|
||||
@@ -875,6 +880,30 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
setLeaveFamilyDialogOpen(true);
|
||||
}
|
||||
|
||||
function openTransferOwnershipDialog() {
|
||||
setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null);
|
||||
setTransferOwnershipDialogOpen(true);
|
||||
}
|
||||
|
||||
async function confirmTransferOwnership() {
|
||||
if (!token || !selectedNewOwnerUserId) {
|
||||
showToast('Выберите нового главу семьи');
|
||||
return;
|
||||
}
|
||||
setTransferringOwnership(true);
|
||||
try {
|
||||
const updated = await transferFamilyOwnership(groupId, selectedNewOwnerUserId, token);
|
||||
setGroup(updated);
|
||||
setTransferOwnershipDialogOpen(false);
|
||||
setFamilyMembersOpen(false);
|
||||
showToast('Управление семьёй передано');
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось передать управление семьёй') ?? 'Ошибка');
|
||||
} finally {
|
||||
setTransferringOwnership(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLeaveFamily() {
|
||||
if (!token) return;
|
||||
if (ownerMustTransfer && !selectedNewOwnerUserId) {
|
||||
@@ -1447,8 +1476,20 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-120px)] min-h-[640px] overflow-hidden rounded-[28px] border border-[#eceef4] bg-[#eef2f8]">
|
||||
<aside className="flex w-[300px] shrink-0 flex-col border-r border-[#e4e8ef] bg-white">
|
||||
<div
|
||||
className={cn(
|
||||
'flex overflow-hidden bg-[#eef2f8]',
|
||||
'h-[calc(100dvh-3.5rem-env(safe-area-inset-bottom)-4.5rem)] min-h-0 lg:h-[calc(100vh-120px)] lg:min-h-[640px]',
|
||||
'rounded-none border-0 lg:rounded-[28px] lg:border lg:border-[#eceef4]'
|
||||
)}
|
||||
>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col border-r border-[#e4e8ef] bg-white',
|
||||
'w-full lg:w-[300px]',
|
||||
mobileChatOpen ? 'hidden lg:flex' : 'flex'
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-[#eceef4] p-4">
|
||||
<button type="button" className="mb-3 flex items-center gap-2 text-sm text-[#667085]" onClick={() => router.push('/family')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -1557,11 +1598,24 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-w-0 flex-1 flex-col bg-[#e6ebf2]">
|
||||
<section
|
||||
className={cn(
|
||||
'min-w-0 flex-1 flex-col bg-[#e6ebf2]',
|
||||
mobileChatOpen ? 'flex' : 'hidden lg:flex'
|
||||
)}
|
||||
>
|
||||
{activeRoom ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-[#dce3ec] bg-white px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Назад к списку чатов"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-[#667085] transition hover:bg-[#f4f5f8] lg:hidden"
|
||||
onClick={() => setActiveRoomId(null)}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
{(activeRoom.type === 'GROUP' || activeRoom.type === 'GENERAL') ? (
|
||||
<label className="group/room-avatar relative block shrink-0 cursor-pointer overflow-hidden rounded-full">
|
||||
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user?.id ?? ''} token={token} />
|
||||
@@ -1623,13 +1677,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
{!activeRoomIsBot ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded-xl"
|
||||
className="h-9 w-9 rounded-xl p-0 sm:h-auto sm:w-auto sm:px-3"
|
||||
aria-label="Поиск по сообщениям"
|
||||
onClick={() => {
|
||||
setChatToolsTab('search');
|
||||
@@ -1641,7 +1695,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded-xl"
|
||||
className="hidden h-9 w-9 rounded-xl p-0 sm:flex sm:h-auto sm:w-auto sm:px-3"
|
||||
aria-label="Перейти к дате"
|
||||
onClick={() => {
|
||||
setChatToolsTab('calendar');
|
||||
@@ -2095,7 +2149,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
onDelete={() => void handleBulkDelete()}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-t border-[#dce3ec] bg-white px-3 py-3">
|
||||
<div className="border-t border-[#dce3ec] bg-white px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm:py-3">
|
||||
{editingMessageId ? (
|
||||
<ChatMessageEditBanner
|
||||
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}
|
||||
@@ -2126,7 +2180,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
{!activeRoomIsE2E ? (
|
||||
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setPollOpen(true)}>
|
||||
<Button size="sm" variant="ghost" className="hidden h-10 w-10 rounded-xl p-0 sm:flex" onClick={() => setPollOpen(true)}>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -2373,6 +2427,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Пригласить в семью
|
||||
</Button>
|
||||
{isFamilyOwner && otherHumanMembers.length > 0 ? (
|
||||
<Button
|
||||
className="mt-2 w-full rounded-xl"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setFamilyMembersOpen(false);
|
||||
openTransferOwnershipDialog();
|
||||
}}
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 h-4 w-4" />
|
||||
Передать управление
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-2 w-full rounded-xl"
|
||||
variant="secondary"
|
||||
@@ -2399,6 +2466,73 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={transferOwnershipDialogOpen} onOpenChange={setTransferOwnershipDialogOpen}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto rounded-[28px] sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Передать управление семьёй</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm leading-relaxed text-[#667085]">
|
||||
Выберите участника, который станет новым главой семьи «{group?.name}». Вы останетесь участником и сможете
|
||||
покинуть семью в любой момент.
|
||||
</p>
|
||||
<div className="mt-4 max-h-[280px] space-y-2 overflow-y-auto">
|
||||
{otherHumanMembers.map((member) => {
|
||||
const selected = selectedNewOwnerUserId === member.userId;
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedNewOwnerUserId(member.userId)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition',
|
||||
selected ? 'bg-[#3390ec]/10 ring-2 ring-[#3390ec]/40' : 'bg-[#f4f5f8] hover:bg-[#eceef4]'
|
||||
)}
|
||||
>
|
||||
<AvatarWithPresence
|
||||
userId={member.userId}
|
||||
displayName={member.displayName}
|
||||
hasAvatar={member.hasAvatar}
|
||||
token={token}
|
||||
isVerified={member.isVerified}
|
||||
verificationIcon={member.verificationIcon}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{member.displayName}</p>
|
||||
<p className="text-xs text-[#667085]">{familyMemberRoleLabel(member.role)}</p>
|
||||
</div>
|
||||
{selected ? <Check className="h-5 w-5 shrink-0 text-[#3390ec]" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
disabled={transferringOwnership}
|
||||
onClick={() => setTransferOwnershipDialogOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={transferringOwnership || !selectedNewOwnerUserId}
|
||||
onClick={() => void confirmTransferOwnership()}
|
||||
>
|
||||
{transferringOwnership ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Передаём...
|
||||
</>
|
||||
) : (
|
||||
'Передать управление'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={leaveFamilyDialogOpen} onOpenChange={setLeaveFamilyDialogOpen}>
|
||||
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -712,7 +712,7 @@ export function MiniFamilyChat() {
|
||||
type="button"
|
||||
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
|
||||
className={cn(
|
||||
'fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec] text-white shadow-lg transition hover:bg-[#2b7fd4] lg:bottom-6 lg:right-6',
|
||||
'fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom))] right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec] text-white shadow-lg transition hover:bg-[#2b7fd4] lg:bottom-6 lg:right-6',
|
||||
miniChatOpen && 'scale-95'
|
||||
)}
|
||||
aria-label={miniChatOpen ? 'Закрыть чат семьи' : 'Открыть чат семьи'}
|
||||
@@ -721,7 +721,7 @@ export function MiniFamilyChat() {
|
||||
</button>
|
||||
|
||||
{miniChatOpen ? (
|
||||
<div className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,380px)] flex-col overflow-hidden rounded-[24px] border border-[#eceef4] bg-white shadow-2xl lg:bottom-[5.5rem] lg:right-6">
|
||||
<div className="fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom)+3.5rem)] right-4 z-50 flex w-[min(92vw,380px)] flex-col overflow-hidden rounded-[24px] border border-[#eceef4] bg-white shadow-2xl lg:bottom-[5.5rem] lg:right-6">
|
||||
<div className="flex items-center gap-2 border-b border-[#eceef4] px-4 py-3">
|
||||
{view === 'chat' || view === 'bot' ? (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setView('picker')}>
|
||||
|
||||
46
apps/frontend/components/id/mobile-nav.tsx
Normal file
46
apps/frontend/components/id/mobile-nav.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FileText, Heart, Home, LockKeyhole, ShieldCheck, UserRound } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||
import { useAuth } from './auth-provider';
|
||||
|
||||
const baseItems = [
|
||||
{ href: '/', label: 'Главная', icon: Home },
|
||||
{ href: '/data', label: 'Данные', icon: UserRound },
|
||||
{ href: '/documents', label: 'Документы', icon: FileText },
|
||||
{ href: '/family', label: 'Семья', icon: Heart },
|
||||
{ href: '/security', label: 'Защита', icon: LockKeyhole }
|
||||
];
|
||||
|
||||
export function MobileNav({ active }: { active: string }) {
|
||||
const { user } = useAuth();
|
||||
const items = user?.canAccessAdmin
|
||||
? [...baseItems, { href: getAdminLandingPath(user), label: 'Админ', icon: ShieldCheck }]
|
||||
: baseItems;
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-[#eceef4] bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 backdrop-blur lg:hidden">
|
||||
<div className="mx-auto flex max-w-lg items-stretch justify-around gap-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
active === item.href || (item.href.startsWith('/admin') && active.startsWith('/admin')) || (item.href === '/family' && active.startsWith('/family'));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center gap-1 rounded-xl px-1 py-1.5 text-[10px] font-medium transition',
|
||||
isActive ? 'text-[#3390ec]' : 'text-[#667085]'
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn('h-5 w-5 shrink-0', isActive && 'stroke-[2.25]')} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex w-full shrink-0 items-center gap-2 md:w-auto">
|
||||
{type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,7 +41,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
||||
</button>
|
||||
) : type === 'select' ? (
|
||||
<select
|
||||
className="h-10 min-w-[220px] rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
|
||||
className="h-10 w-full min-w-0 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3] md:min-w-[220px] md:w-auto"
|
||||
value={setting.value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
@@ -55,7 +55,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
||||
<textarea
|
||||
value={setting.value}
|
||||
rows={3}
|
||||
className="min-h-[88px] w-full min-w-[280px] resize-y rounded-xl border border-[#e4e7ec] bg-white px-3 py-2 text-sm outline-none focus:border-[#98a2b3] md:w-[360px]"
|
||||
className="min-h-[88px] w-full min-w-0 resize-y rounded-xl border border-[#e4e7ec] bg-white px-3 py-2 text-sm outline-none focus:border-[#98a2b3] md:w-[360px]"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -63,7 +63,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
||||
<Input
|
||||
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
||||
value={setting.value}
|
||||
className={setting.isSecret ? 'w-full min-w-[240px] bg-white md:w-[280px]' : 'w-full min-w-[180px] bg-white md:w-[220px]'}
|
||||
className={setting.isSecret ? 'w-full min-w-0 bg-white md:w-[280px]' : 'w-full min-w-0 bg-white md:w-[220px]'}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
||||
|
||||
@@ -1,25 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { Sidebar } from './sidebar';
|
||||
import { MobileNav } from './mobile-nav';
|
||||
import { UserMenu } from './user-menu';
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||
import { EmojiSpriteProvider } from '@/components/chat/emoji-sprite-provider';
|
||||
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
|
||||
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
|
||||
import { BrandLogo } from '@/components/id/brand-logo';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
|
||||
export function IdShell({
|
||||
active,
|
||||
children,
|
||||
wide = false,
|
||||
fullBleed = false
|
||||
}: {
|
||||
active: string;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
fullBleed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FamilyOverlayProvider>
|
||||
<EmojiSpriteProvider />
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen overflow-x-hidden bg-white">
|
||||
<Sidebar active={active} />
|
||||
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
|
||||
<div className="fixed left-0 right-0 top-0 z-40 flex items-center justify-between border-b border-[#eceef4] bg-white/95 px-4 py-3 backdrop-blur lg:hidden">
|
||||
<Link href="/" className="shrink-0">
|
||||
<BrandLogo size="sm" variant="dark" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
<div className="fixed right-4 top-4 z-40 hidden items-center gap-2 lg:right-8 lg:top-6 lg:flex">
|
||||
<NotificationBell />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<main className="px-5 py-8 lg:pl-[220px] lg:pr-8">
|
||||
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</div>
|
||||
<main
|
||||
className={cn(
|
||||
'pb-[calc(4.5rem+env(safe-area-inset-bottom))] pt-14 lg:pb-8 lg:pt-8 lg:pl-[220px] lg:pr-8',
|
||||
fullBleed && 'px-0 pb-[calc(4.5rem+env(safe-area-inset-bottom))] pt-14 lg:px-8'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto w-full min-w-0',
|
||||
fullBleed ? 'max-w-none' : wide ? 'max-w-[920px] px-4 sm:px-5 lg:px-0' : 'max-w-[560px] px-4 sm:px-5 lg:px-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<MobileNav active={active} />
|
||||
<MiniFamilyChat />
|
||||
</div>
|
||||
</FamilyOverlayProvider>
|
||||
|
||||
@@ -991,6 +991,13 @@ export async function leaveFamilyGroup(groupId: string, token?: string | null, n
|
||||
);
|
||||
}
|
||||
|
||||
export async function transferFamilyOwnership(groupId: string, newOwnerUserId: string, token?: string | null) {
|
||||
return apiFetch<FamilyGroup>(`/family/groups/${groupId}/transfer-ownership`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ newOwnerUserId })
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
|
||||
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
|
||||
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
|
||||
|
||||
@@ -912,6 +912,11 @@ export class AuthGrpcController {
|
||||
return this.family.leaveFamily(command.requesterId, command.groupId, command.newOwnerUserId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'TransferFamilyOwnership')
|
||||
transferFamilyOwnership(command: { requesterId: string; groupId: string; newOwnerUserId: string }) {
|
||||
return this.family.transferFamilyOwnership(command.requesterId, command.groupId, command.newOwnerUserId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'SendFamilyInvite')
|
||||
async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
|
||||
const invite = await this.family.sendInvite(command.requesterId, command.groupId, {
|
||||
|
||||
@@ -282,6 +282,34 @@ export class FamilyService {
|
||||
|
||||
}
|
||||
|
||||
async transferFamilyOwnership(requesterId: string, groupId: string, newOwnerUserId: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertOwner(group, requesterId);
|
||||
|
||||
if (newOwnerUserId === requesterId) {
|
||||
|
||||
throw new BadRequestException('Нельзя передать семью самому себе');
|
||||
|
||||
}
|
||||
|
||||
const otherHumans = this.getHumanMembers(group.members).filter((item) => item.userId !== requesterId);
|
||||
|
||||
if (!otherHumans.some((item) => item.userId === newOwnerUserId)) {
|
||||
|
||||
throw new BadRequestException('Новый глава семьи должен быть участником семьи');
|
||||
|
||||
}
|
||||
|
||||
await this.transferOwnership(group, requesterId, newOwnerUserId);
|
||||
|
||||
const updated = await this.getGroupWithMembers(groupId);
|
||||
|
||||
return this.toGroup(updated, requesterId);
|
||||
|
||||
}
|
||||
|
||||
private async destroyGroupWithCleanup(group: {
|
||||
|
||||
id: string;
|
||||
|
||||
@@ -35,6 +35,7 @@ service FamilyService {
|
||||
rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse);
|
||||
rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse);
|
||||
rpc LeaveFamilyGroup (LeaveFamilyGroupRequest) returns (LeaveFamilyGroupResponse);
|
||||
rpc TransferFamilyOwnership (TransferFamilyOwnershipRequest) returns (FamilyGroupResponse);
|
||||
rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse);
|
||||
rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse);
|
||||
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
|
||||
@@ -253,6 +254,12 @@ message LeaveFamilyGroupResponse {
|
||||
bool deleted = 2;
|
||||
}
|
||||
|
||||
message TransferFamilyOwnershipRequest {
|
||||
string requesterId = 1;
|
||||
string groupId = 2;
|
||||
string newOwnerUserId = 3;
|
||||
}
|
||||
|
||||
message SendFamilyInviteRequest {
|
||||
string requesterId = 1;
|
||||
string groupId = 2;
|
||||
|
||||
Reference in New Issue
Block a user