Add for leave family
This commit is contained in:
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
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('Семья')
|
@ApiTags('Семья')
|
||||||
@ApiBearerAuth()
|
@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')
|
@Get('groups/:groupId/invite-search')
|
||||||
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
|
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
|
||||||
async searchInviteUsers(
|
async searchInviteUsers(
|
||||||
|
|||||||
@@ -161,3 +161,10 @@ export class LeaveFamilyGroupDto {
|
|||||||
newOwnerUserId?: string;
|
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>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IdShell active="/documents" wide>
|
<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>
|
<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">
|
<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);
|
const { groupId } = use(params);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IdShell active="/family" wide>
|
<IdShell active="/family" fullBleed>
|
||||||
<FamilyGroupPageContent groupId={groupId} />
|
<FamilyGroupPageContent groupId={groupId} />
|
||||||
</IdShell>
|
</IdShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export default function FamilyPage() {
|
|||||||
return (
|
return (
|
||||||
<IdShell active="/family" wide>
|
<IdShell active="/family" wide>
|
||||||
<p className="text-sm text-[#667085]">Семья</p>
|
<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 text-[#667085]">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
<p className="mt-2 text-sm text-[#667085] sm:text-base">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3">
|
<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="Название семьи" />
|
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" className="min-w-0 flex-1" />
|
||||||
<Button onClick={() => void createGroup()} disabled={creating || !name.trim()}>
|
<Button className="shrink-0" onClick={() => void createGroup()} disabled={creating || !name.trim()}>
|
||||||
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ body {
|
|||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-root {
|
.rdp-root {
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export default function SecurityPage() {
|
|||||||
return (
|
return (
|
||||||
<IdShell active="/security">
|
<IdShell active="/security">
|
||||||
<p className="text-sm text-[#667085]">Безопасность</p>
|
<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>
|
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
|
||||||
|
|
||||||
<div className="mt-8 space-y-2">
|
<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 { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Camera,
|
Camera,
|
||||||
@@ -95,6 +96,7 @@ import {
|
|||||||
setChatRoomPinned,
|
setChatRoomPinned,
|
||||||
setChatRoomMuted,
|
setChatRoomMuted,
|
||||||
toggleChatMessageReaction,
|
toggleChatMessageReaction,
|
||||||
|
transferFamilyOwnership,
|
||||||
updateFamilyGroup,
|
updateFamilyGroup,
|
||||||
uploadMediaObject,
|
uploadMediaObject,
|
||||||
voteChatPoll
|
voteChatPoll
|
||||||
@@ -281,7 +283,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
|
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
|
||||||
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
|
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
|
||||||
const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
|
const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
|
||||||
|
const [transferOwnershipDialogOpen, setTransferOwnershipDialogOpen] = useState(false);
|
||||||
const [leavingFamily, setLeavingFamily] = useState(false);
|
const [leavingFamily, setLeavingFamily] = useState(false);
|
||||||
|
const [transferringOwnership, setTransferringOwnership] = useState(false);
|
||||||
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
|
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
|
||||||
const [deletingFamily, setDeletingFamily] = useState(false);
|
const [deletingFamily, setDeletingFamily] = useState(false);
|
||||||
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = 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 ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0);
|
||||||
const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0);
|
const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0);
|
||||||
|
const mobileChatOpen = Boolean(activeRoomId);
|
||||||
const isChatCreator = activeRoom?.createdById === user?.id;
|
const isChatCreator = activeRoom?.createdById === user?.id;
|
||||||
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
|
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
|
||||||
const canManageActiveBot = Boolean(
|
const canManageActiveBot = Boolean(
|
||||||
@@ -875,6 +880,30 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
setLeaveFamilyDialogOpen(true);
|
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() {
|
async function confirmLeaveFamily() {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (ownerMustTransfer && !selectedNewOwnerUserId) {
|
if (ownerMustTransfer && !selectedNewOwnerUserId) {
|
||||||
@@ -1447,8 +1476,20 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-120px)] min-h-[640px] overflow-hidden rounded-[28px] border border-[#eceef4] bg-[#eef2f8]">
|
<div
|
||||||
<aside className="flex w-[300px] shrink-0 flex-col border-r border-[#e4e8ef] bg-white">
|
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">
|
<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')}>
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -1557,11 +1598,24 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 ? (
|
{activeRoom ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between border-b border-[#dce3ec] bg-white px-4 py-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 items-center gap-3">
|
<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') ? (
|
{(activeRoom.type === 'GROUP' || activeRoom.type === 'GENERAL') ? (
|
||||||
<label className="group/room-avatar relative block shrink-0 cursor-pointer overflow-hidden rounded-full">
|
<label className="group/room-avatar relative block shrink-0 cursor-pointer overflow-hidden rounded-full">
|
||||||
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user?.id ?? ''} token={token} />
|
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user?.id ?? ''} token={token} />
|
||||||
@@ -1623,13 +1677,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
{!activeRoomIsBot ? (
|
{!activeRoomIsBot ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
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="Поиск по сообщениям"
|
aria-label="Поиск по сообщениям"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatToolsTab('search');
|
setChatToolsTab('search');
|
||||||
@@ -1641,7 +1695,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
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="Перейти к дате"
|
aria-label="Перейти к дате"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatToolsTab('calendar');
|
setChatToolsTab('calendar');
|
||||||
@@ -2095,7 +2149,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
onDelete={() => void handleBulkDelete()}
|
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 ? (
|
{editingMessageId ? (
|
||||||
<ChatMessageEditBanner
|
<ChatMessageEditBanner
|
||||||
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}
|
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" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{!activeRoomIsE2E ? (
|
{!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" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2373,6 +2427,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Пригласить в семью
|
Пригласить в семью
|
||||||
</Button>
|
</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
|
<Button
|
||||||
className="mt-2 w-full rounded-xl"
|
className="mt-2 w-full rounded-xl"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -2399,6 +2466,73 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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}>
|
<Dialog open={leaveFamilyDialogOpen} onOpenChange={setLeaveFamilyDialogOpen}>
|
||||||
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
|
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -712,7 +712,7 @@ export function MiniFamilyChat() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
|
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
|
||||||
className={cn(
|
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'
|
miniChatOpen && 'scale-95'
|
||||||
)}
|
)}
|
||||||
aria-label={miniChatOpen ? 'Закрыть чат семьи' : 'Открыть чат семьи'}
|
aria-label={miniChatOpen ? 'Закрыть чат семьи' : 'Открыть чат семьи'}
|
||||||
@@ -721,7 +721,7 @@ export function MiniFamilyChat() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{miniChatOpen ? (
|
{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">
|
<div className="flex items-center gap-2 border-b border-[#eceef4] px-4 py-3">
|
||||||
{view === 'chat' || view === 'bot' ? (
|
{view === 'chat' || view === 'bot' ? (
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setView('picker')}>
|
<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>
|
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
||||||
</div>
|
</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' ? (
|
{type === 'boolean' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -41,7 +41,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
|||||||
</button>
|
</button>
|
||||||
) : type === 'select' ? (
|
) : type === 'select' ? (
|
||||||
<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}
|
value={setting.value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
>
|
>
|
||||||
@@ -55,7 +55,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={setting.value}
|
value={setting.value}
|
||||||
rows={3}
|
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)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -63,7 +63,7 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
|||||||
<Input
|
<Input
|
||||||
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
||||||
value={setting.value}
|
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)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
||||||
|
|||||||
@@ -1,25 +1,61 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
|
import { MobileNav } from './mobile-nav';
|
||||||
import { UserMenu } from './user-menu';
|
import { UserMenu } from './user-menu';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||||
import { EmojiSpriteProvider } from '@/components/chat/emoji-sprite-provider';
|
import { EmojiSpriteProvider } from '@/components/chat/emoji-sprite-provider';
|
||||||
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
|
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
|
||||||
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
|
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 (
|
return (
|
||||||
<FamilyOverlayProvider>
|
<FamilyOverlayProvider>
|
||||||
<EmojiSpriteProvider />
|
<EmojiSpriteProvider />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen overflow-x-hidden bg-white">
|
||||||
<Sidebar active={active} />
|
<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 />
|
<NotificationBell />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
<main className="px-5 py-8 lg:pl-[220px] lg:pr-8">
|
</div>
|
||||||
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</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={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>
|
</main>
|
||||||
|
<MobileNav active={active} />
|
||||||
<MiniFamilyChat />
|
<MiniFamilyChat />
|
||||||
</div>
|
</div>
|
||||||
</FamilyOverlayProvider>
|
</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) {
|
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
|
||||||
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
|
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
|
||||||
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
|
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);
|
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')
|
@GrpcMethod('FamilyService', 'SendFamilyInvite')
|
||||||
async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
|
async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
|
||||||
const invite = await this.family.sendInvite(command.requesterId, command.groupId, {
|
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: {
|
private async destroyGroupWithCleanup(group: {
|
||||||
|
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ service FamilyService {
|
|||||||
rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse);
|
rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse);
|
||||||
rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse);
|
rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse);
|
||||||
rpc LeaveFamilyGroup (LeaveFamilyGroupRequest) returns (LeaveFamilyGroupResponse);
|
rpc LeaveFamilyGroup (LeaveFamilyGroupRequest) returns (LeaveFamilyGroupResponse);
|
||||||
|
rpc TransferFamilyOwnership (TransferFamilyOwnershipRequest) returns (FamilyGroupResponse);
|
||||||
rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse);
|
rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse);
|
||||||
rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse);
|
rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse);
|
||||||
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
|
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
|
||||||
@@ -253,6 +254,12 @@ message LeaveFamilyGroupResponse {
|
|||||||
bool deleted = 2;
|
bool deleted = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message TransferFamilyOwnershipRequest {
|
||||||
|
string requesterId = 1;
|
||||||
|
string groupId = 2;
|
||||||
|
string newOwnerUserId = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message SendFamilyInviteRequest {
|
message SendFamilyInviteRequest {
|
||||||
string requesterId = 1;
|
string requesterId = 1;
|
||||||
string groupId = 2;
|
string groupId = 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user