Merge: 토큰 갱신/로그인 유지 수정 + 일정/업무 UI 다듬기

fix/auth-token-refresh 머지:
- 401 자동 refresh 가 /auth/me 에서 동작하도록 수정(짧은 로그아웃 해결)
- keepLogin 구현(persistent 세션 플래그·세션/영속 쿠키)
- 일정 모달 placeholder·참석자 콤보 UI, AvatarStack 공용화(일정 참석자·업무 담당자)
This commit is contained in:
2026-06-23 11:33:42 +09:00
10 changed files with 507 additions and 390 deletions
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 로그인 유지(keepLogin) — refresh 세션에 persistent 플래그 추가.
// true(기본): 영속 쿠키(refresh TTL), false: 세션 쿠키(브라우저 종료 시 만료).
// 기존 세션은 기존 동작(영속) 유지를 위해 default true.
export class AddRefreshSessionPersistent1782400000000 implements MigrationInterface {
name = 'AddRefreshSessionPersistent1782400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_sessions" ADD "persistent" boolean NOT NULL DEFAULT true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_sessions" DROP COLUMN "persistent"`,
);
}
}
+33 -10
View File
@@ -60,21 +60,23 @@ export class AuthController {
};
}
// access/refresh 토큰을 쿠키로 설정
// access/refresh 토큰을 쿠키로 설정.
// persistent=true 면 maxAge 부여(영속 쿠키), false 면 maxAge 생략(세션 쿠키 — 브라우저 종료 시 만료).
private setAuthCookies(
res: Response,
accessToken: string,
refreshToken: string,
persistent: boolean,
): void {
res.cookie('access_token', accessToken, {
...this.cookieBase(),
path: '/',
maxAge: ACCESS_MAX_AGE,
...(persistent ? { maxAge: ACCESS_MAX_AGE } : {}),
});
res.cookie('refresh_token', refreshToken, {
...this.cookieBase(),
path: REFRESH_COOKIE_PATH,
maxAge: REFRESH_MAX_AGE,
...(persistent ? { maxAge: REFRESH_MAX_AGE } : {}),
});
}
@@ -88,9 +90,22 @@ export class AuthController {
}
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
private async loginWith(res: Response, user: PublicUser): Promise<void> {
const tokens = await this.authService.issueTokensForNewSession(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
// keepLogin: 로그인 유지 여부(영속 쿠키 vs 세션 쿠키)
private async loginWith(
res: Response,
user: PublicUser,
keepLogin: boolean,
): Promise<void> {
const tokens = await this.authService.issueTokensForNewSession(
user,
keepLogin,
);
this.setAuthCookies(
res,
tokens.accessToken,
tokens.refreshToken,
tokens.persistent,
);
}
@Post('signup')
@@ -171,7 +186,8 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.validateUser(dto);
await this.loginWith(res, user);
// keepLogin 미지정(API 직접 호출 등)이면 세션 쿠키(false)로 안전하게 처리
await this.loginWith(res, user, dto.keepLogin ?? false);
return user;
}
@@ -192,7 +208,8 @@ export class AuthController {
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
await this.loginWith(res, req.user as PublicUser, true);
res.redirect(`${this.webUrl()}/projects`);
}
@@ -213,7 +230,8 @@ export class AuthController {
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
await this.loginWith(res, req.user as PublicUser, true);
res.redirect(`${this.webUrl()}/projects`);
}
@@ -250,7 +268,12 @@ export class AuthController {
const tokens = await this.authService.rotateTokens(
cookies?.refresh_token,
);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
this.setAuthCookies(
res,
tokens.accessToken,
tokens.refreshToken,
tokens.persistent,
);
return null;
} catch (e) {
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
+19 -6
View File
@@ -31,6 +31,11 @@ export interface TokenPair {
refreshToken: string;
}
// 세션 토큰 + 쿠키 영속 여부(keepLogin) — 컨트롤러가 쿠키 maxAge 결정에 사용
export interface SessionTokens extends TokenPair {
persistent: boolean;
}
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string;
@@ -226,8 +231,12 @@ export class AuthService {
return UserService.toPublic(user);
}
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급
async issueTokensForNewSession(user: PublicUser): Promise<TokenPair> {
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급.
// keepLogin(persistent): true 면 영속 쿠키, false 면 세션 쿠키로 발급되도록 세션에 기록.
async issueTokensForNewSession(
user: PublicUser,
keepLogin: boolean,
): Promise<SessionTokens> {
// 만료된 이 사용자의 세션 정리(누적 방지)
await this.refreshRepo
.createQueryBuilder()
@@ -243,15 +252,17 @@ export class AuthService {
currentJti: jti,
prevJti: null,
prevAt: null,
persistent: keepLogin,
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
}),
);
return this.signTokens(user.id, user.email, session.id, jti);
const tokens = await this.signTokens(user.id, user.email, session.id, jti);
return { ...tokens, persistent: keepLogin };
}
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
async rotateTokens(refreshToken: string | undefined): Promise<TokenPair> {
async rotateTokens(refreshToken: string | undefined): Promise<SessionTokens> {
if (!refreshToken) throw new UnauthorizedException();
let payload: JwtPayload;
try {
@@ -281,12 +292,13 @@ export class AuthService {
session.currentJti = newJti;
session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS);
await this.refreshRepo.save(session);
return this.signTokens(
const tokens = await this.signTokens(
session.user.id,
session.user.email,
session.id,
newJti,
);
return { ...tokens, persistent: session.persistent };
}
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
@@ -295,12 +307,13 @@ export class AuthService {
session.prevAt &&
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
) {
return this.signTokens(
const tokens = await this.signTokens(
session.user.id,
session.user.email,
session.id,
session.currentJti,
);
return { ...tokens, persistent: session.persistent };
}
// 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃)
@@ -34,6 +34,11 @@ export class RefreshSession {
@Column({ name: 'prev_at', type: 'timestamptz', nullable: true })
prevAt!: Date | null;
// 로그인 유지(keepLogin) 여부 — true 면 영속 쿠키(refresh TTL 만큼), false 면
// 세션 쿠키(브라우저 종료 시 만료). refresh 회전 시에도 이 값을 따라 쿠키를 재발급한다.
@Column({ type: 'boolean', default: true })
persistent!: boolean;
// 세션 만료(refresh TTL) — 만료분 정리용
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@@ -0,0 +1,205 @@
<script setup lang="ts">
// 아바타 스택 + 오버플로(+N) + 클릭 팝오버 목록 공용 컴포넌트.
// 좁은 영역에서 다수의 사람(참석자/담당자 등)을 컴팩트하게 표시하고,
// 클릭하면 전체 목록(아바타 + 이름 + 선택적 보조문구 sub)을 펼친다.
import { computed, ref } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useClickOutside } from '@/composables/useClickOutside'
interface StackPerson {
id: string
name: string
avatarUrl?: string | null
// 팝오버 목록의 이름 아래 보조문구(예: 역할). 없으면 표시 안 함
sub?: string | null
}
const props = withDefaults(
defineProps<{ people: StackPerson[]; max?: number }>(),
{ max: 6 },
)
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
// max 초과 시 (max-1)개만 보이고 나머지는 +N
const shown = computed(() =>
props.people.length > props.max
? props.people.slice(0, props.max - 1)
: props.people,
)
const extra = computed(() =>
props.people.length > props.max ? props.people.length - (props.max - 1) : 0,
)
</script>
<template>
<div
ref="rootRef"
class="as"
>
<button
type="button"
class="as-trigger"
:class="{ open }"
@click="open = !open"
>
<span class="as-stack">
<span
v-for="p in shown"
:key="p.id"
class="as-av"
><UserAvatar
:url="p.avatarUrl"
:name="p.name"
/></span>
<span
v-if="extra > 0"
class="as-more"
>+{{ extra }}</span>
</span>
<span class="as-chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="open"
class="as-panel"
>
<div
v-for="p in people"
:key="p.id"
class="as-prow"
>
<span class="as-av sm"><UserAvatar
:url="p.avatarUrl"
:name="p.name"
/></span>
<span class="as-pinfo">
<span class="as-pnm">{{ p.name }}</span>
<span
v-if="p.sub"
class="as-psub"
>{{ p.sub }}</span>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.as {
position: relative;
display: inline-block;
}
.as-trigger {
display: inline-flex;
align-items: center;
gap: 0.5625rem;
border: none;
background: none;
cursor: pointer;
padding: 0.3125rem 0.5rem 0.3125rem 0.3125rem;
margin: -0.3125rem -0.5rem -0.3125rem -0.3125rem;
border-radius: 0.5625rem;
}
.as-trigger:hover,
.as-trigger.open {
background: #f1f2f4;
}
.as-stack {
display: inline-flex;
align-items: center;
}
.as-av {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
flex-shrink: 0;
}
.as-stack .as-av,
.as-stack .as-more {
margin-left: -0.5625rem;
box-shadow: 0 0 0 2px var(--panel);
}
.as-stack .as-av:first-child {
margin-left: 0;
}
.as-more {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
background: #eceef1;
color: var(--text-2);
flex-shrink: 0;
}
.as-chev {
color: var(--text-3);
display: grid;
transition: transform 0.15s ease;
}
.as-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
.as-trigger.open .as-chev {
transform: rotate(180deg);
}
.as-panel {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
min-width: 12.5rem;
max-width: 16.25rem;
max-height: 15rem;
overflow-y: auto;
z-index: 20;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-pop);
padding: 0.3125rem;
}
.as-prow {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
white-space: nowrap;
}
.as-prow:hover {
background: #f4f5f7;
}
.as-av.sm {
width: 1.5rem;
height: 1.5rem;
}
.as-pinfo {
display: flex;
flex-direction: column;
min-width: 0;
}
.as-pnm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
}
.as-psub {
font-size: 0.719rem;
color: var(--text-3);
}
</style>
@@ -1,38 +0,0 @@
<script setup lang="ts">
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 를 size(px)로 감싸는 얇은 어댑터.
// 렌더링(이미지/실루엣)은 UserAvatar 가 단일 책임지고, 여기선 크기/원형 래퍼만 담당.
// (.sa 클래스는 상세 패널 아바타 스택 CSS 의 훅으로 유지)
// size 는 px 로 받되 rem(÷16)으로 렌더 — 앱 전체 rem 스케일과 정합.
import { computed } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
const props = withDefaults(
defineProps<{
name?: string
avatarUrl?: string | null
size?: number
}>(),
{ name: '', avatarUrl: null, size: 21 },
)
const dim = computed(() => `${props.size / 16}rem`)
</script>
<template>
<span
class="sa"
:style="{ width: dim, height: dim }"
>
<UserAvatar
:url="avatarUrl"
:name="name"
/>
</span>
</template>
<style scoped>
.sa {
display: block;
flex-shrink: 0;
border-radius: 50%;
}
</style>
@@ -1,8 +1,7 @@
<script setup lang="ts">
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
import { computed, ref, watch } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import AvatarStack from '@/components/common/AvatarStack.vue'
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
@@ -34,21 +33,7 @@ const dur = computed(() =>
ev.value && multi.value ? spanDays(ev.value.start, ev.value.end) : 0,
)
// 참석자 스택 토글
const attOpen = ref(false)
const attRef = ref<HTMLElement | null>(null)
useClickOutside(attOpen, () => (attOpen.value = false), [attRef], { esc: false })
const MAX = 6
const attendees = computed(() => ev.value?.attendees ?? [])
const shownAtt = computed(() =>
attendees.value.length > MAX
? attendees.value.slice(0, MAX - 1)
: attendees.value,
)
const extra = computed(() =>
attendees.value.length > MAX ? attendees.value.length - (MAX - 1) : 0,
)
</script>
<template>
@@ -122,57 +107,10 @@ const extra = computed(() =>
<span class="dt-k">참석자</span>
<span class="dt-v">
<template v-if="ev.allHands"> 직원 대상</template>
<div
<AvatarStack
v-else-if="attendees.length > 0"
ref="attRef"
class="att-stack"
>
<button
class="att-trigger"
:class="{ open: attOpen }"
@click="attOpen = !attOpen"
>
<span class="avstack">
<ScheduleAvatar
v-for="p in shownAtt"
:key="p.id"
:name="p.name"
:avatar-url="p.avatarUrl"
:size="30"
/>
<span
v-if="extra > 0"
class="av-more"
>+{{ extra }}</span>
</span>
<span class="att-chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="attOpen"
class="att-panel"
>
<div
v-for="p in attendees"
:key="p.id"
class="att-row"
>
<ScheduleAvatar
:name="p.name"
:avatar-url="p.avatarUrl"
:size="24"
/>{{ p.name }}
</div>
</div>
</div>
:people="attendees"
/>
<template v-else>참석자 없음</template>
</span>
</div>
@@ -347,90 +285,6 @@ const extra = computed(() =>
line-height: 1.7;
white-space: pre-wrap;
}
.att-stack {
position: relative;
margin: -0.1875rem 0;
}
.att-trigger {
display: inline-flex;
align-items: center;
gap: 0.5625rem;
border: none;
background: none;
cursor: pointer;
padding: 0.3125rem 0.5rem 0.3125rem 0.3125rem;
margin: -0.3125rem -0.5rem -0.3125rem -0.3125rem;
border-radius: 0.5625rem;
}
.att-trigger:hover,
.att-trigger.open {
background: #f1f2f4;
}
.avstack {
display: inline-flex;
align-items: center;
}
.avstack :deep(.sa),
.avstack .av-more {
margin-left: -0.5625rem;
box-shadow: 0 0 0 2px var(--panel);
}
.avstack :deep(.sa):first-child {
margin-left: 0;
}
.av-more {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
background: #eceef1;
color: var(--text-2);
flex-shrink: 0;
}
.att-chev {
color: var(--text-3);
display: grid;
transition: transform 0.15s ease;
}
.att-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
.att-trigger.open .att-chev {
transform: rotate(180deg);
}
.att-panel {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
min-width: 12.5rem;
max-width: 16.25rem;
max-height: 15rem;
overflow-y: auto;
z-index: 20;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-md);
padding: 0.3125rem;
}
.att-row {
display: flex;
align-items: center;
gap: 0.5625rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text);
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
white-space: nowrap;
}
.att-row:hover {
background: #f4f5f7;
}
.dt-foot {
flex-shrink: 0;
padding: 0.875rem 1.375rem;
@@ -2,8 +2,7 @@
// 일정 생성/수정 모달
import { computed, ref, watch } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
@@ -71,29 +70,46 @@ const valid = computed(
!!startT.value,
)
// 참석자 멀티셀렉트
const msOpen = ref(false)
const msRef = ref<HTMLElement | null>(null)
const msLabel = computed(() => {
if (allHands.value) return '전 직원'
if (attendeeIds.value.length === 0) return '참석자 선택'
// 참석자 — 검색 입력 + 후보 드롭다운 + 선택 칩 (업무 생성의 담당자 등록과 동일 방식)
const attendeeQuery = ref('')
const showAttendeeDropdown = ref(false)
// 선택된 참석자(칩 표시용)
const selectedAttendees = computed(() =>
attendeeIds.value
.map((id) => props.people.find((p) => p.id === id))
.filter((p): p is ApiSchedulePerson => !!p),
)
// 후보 = 미선택 사용자, 이름 검색 필터
const attendeeCandidates = computed(() => {
const selected = new Set(attendeeIds.value)
const kw = attendeeQuery.value.trim()
return props.people
.filter((p) => attendeeIds.value.includes(p.id))
.map((p) => p.name)
.join(', ')
.filter((p) => !selected.has(p.id))
.filter((p) => !kw || p.name.includes(kw))
})
function toggleAll(): void {
allHands.value = !allHands.value
if (allHands.value) attendeeIds.value = []
}
function togglePerson(id: string): void {
function addAttendee(id: string): void {
allHands.value = false
attendeeIds.value = attendeeIds.value.includes(id)
? attendeeIds.value.filter((x) => x !== id)
: [...attendeeIds.value, id]
if (!attendeeIds.value.includes(id)) {
attendeeIds.value = [...attendeeIds.value, id]
}
attendeeQuery.value = ''
}
function removeAttendee(id: string): void {
attendeeIds.value = attendeeIds.value.filter((x) => x !== id)
}
function selectAllHands(): void {
allHands.value = true
attendeeIds.value = []
attendeeQuery.value = ''
showAttendeeDropdown.value = false
}
function clearAllHands(): void {
allHands.value = false
}
function onAttendeeBlur(): void {
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
setTimeout(() => (showAttendeeDropdown.value = false), 120)
}
// 참석자 멀티셀렉트 바깥클릭 닫기(ESC 는 모달 닫기에서 처리)
useClickOutside(msOpen, () => (msOpen.value = false), [msRef], { esc: false })
function submit(): void {
if (!valid.value) return
@@ -121,7 +137,7 @@ function submit(): void {
<input
v-model="title"
class="form-input"
placeholder="일정 제목"
placeholder="일정 제목을 입력하세요"
autofocus
>
</label>
@@ -188,23 +204,22 @@ function submit(): void {
<input
v-model="place"
class="form-input"
placeholder="장소 또는 화상"
placeholder="장소를 입력하세요"
>
</label>
<div class="form-field">
<span class="form-label">참석자</span>
<div
ref="msRef"
class="ms"
>
<button
type="button"
class="ms-field"
:class="{ empty: !allHands && attendeeIds.length === 0, open: msOpen }"
@click="msOpen = !msOpen"
>
<span class="ms-val">{{ msLabel }}</span>
<span class="chev">
<div class="combo">
<div class="combo-field">
<input
v-model="attendeeQuery"
class="combo-input"
placeholder="참석자를 검색하세요"
autocomplete="off"
@focus="showAttendeeDropdown = true"
@blur="onAttendeeBlur"
>
<span class="combo-caret">
<svg
viewBox="0 0 24 24"
fill="none"
@@ -214,76 +229,82 @@ function submit(): void {
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
</div>
<!-- 참석자 후보 드롭다운 -->
<div
v-if="msOpen"
class="ms-panel"
v-if="showAttendeeDropdown"
class="assignee-dd"
>
<!-- 직원(전체 대상) -->
<button
v-if="!allHands && !attendeeQuery.trim()"
type="button"
class="ms-item"
:class="{ on: allHands }"
@click="toggleAll"
class="assignee-dd-item"
@mousedown.prevent="selectAllHands"
>
<span class="ms-all"></span>
<span class="ms-name"> 직원</span>
<span
v-if="allHands"
class="ms-check"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
<span class="ad-all"></span>
<span class="ad-nm"> 직원</span>
</button>
<div class="ms-div" />
<button
v-for="p in people"
v-for="p in attendeeCandidates"
:key="p.id"
type="button"
class="ms-item"
:class="{ on: attendeeIds.includes(p.id) }"
@click="togglePerson(p.id)"
class="assignee-dd-item"
@mousedown.prevent="addAttendee(p.id)"
>
<ScheduleAvatar
<span class="avatar"><UserAvatar
:url="p.avatarUrl"
:name="p.name"
:avatar-url="p.avatarUrl"
:size="20"
/>
<span class="ms-name">{{ p.name }}</span>
<span
v-if="attendeeIds.includes(p.id)"
class="ms-check"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
/></span>
<span class="ad-nm">{{ p.name }}</span>
</button>
<div
v-if="people.length === 0"
class="ms-empty"
v-if="attendeeCandidates.length === 0 && (allHands || attendeeQuery.trim())"
class="assignee-dd-msg"
>
등록된 사용자가 없습니다
{{ people.length === 0 ? '등록된 사용자가 없습니다.' : '추가할 사용자가 없습니다.' }}
</div>
</div>
</div>
<!-- 선택된 참석자 -->
<div
v-if="allHands || selectedAttendees.length"
class="assignee-chips"
>
<span
v-if="allHands"
class="chip"
>
<span class="ad-all sm"></span> 직원
<span
class="x"
@click="clearAllHands"
>×</span>
</span>
<template v-else>
<span
v-for="p in selectedAttendees"
:key="p.id"
class="chip"
>
<span class="avatar"><UserAvatar
:url="p.avatarUrl"
:name="p.name"
/></span>
{{ p.name }}
<span
class="x"
@click="removeAttendee(p.id)"
>×</span>
</span>
</template>
</div>
</div>
<label class="form-field"><span class="form-label">설명</span>
<textarea
v-model="desc"
class="form-input form-textarea"
placeholder="메모"
placeholder="일정에 대한 메모를 입력하세요 (선택)"
rows="3"
/>
</label>
@@ -322,123 +343,146 @@ function submit(): void {
color: var(--text-3);
}
/* 참석자 멀티셀렉트 */
.ms {
/* 참석자 — 업무 생성의 담당자 등록과 동일한 콤보(검색 입력 + 후보 드롭다운 + 선택 칩) */
.combo {
position: relative;
}
.ms-field {
.combo-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
width: 100%;
height: 2.5rem;
padding: 0 0.625rem 0 0.75rem;
gap: 0.375rem;
padding: 0.4375rem 0.5rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
cursor: pointer;
text-align: left;
}
.ms-field.empty .ms-val {
color: var(--text-3);
}
.ms-field.open {
.combo-field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.ms-field .ms-val {
.combo-input {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 4.375rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
color: var(--text);
}
.ms-field .chev {
display: grid;
.combo-input::placeholder {
color: var(--text-3);
}
.ms-field .chev svg {
width: 0.9375rem;
height: 0.9375rem;
.combo-caret {
color: var(--text-3);
display: grid;
place-items: center;
margin-left: auto;
flex-shrink: 0;
}
.ms-panel {
.combo-caret svg {
width: 1rem;
height: 1rem;
}
.assignee-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
max-height: 13.75rem;
overflow-y: auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: var(--shadow-pop);
padding: 0.375rem;
max-height: 14rem;
overflow-y: auto;
}
.ms-item {
.assignee-dd-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
border: none;
background: none;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.ms-item:hover {
.assignee-dd-item:hover {
background: #f5f6f8;
}
.ms-item.on {
color: var(--text);
.assignee-dd-item .avatar {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
}
.ms-item .ms-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
.ad-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.ms-item .ms-check {
.assignee-dd-msg {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 20px;
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
font-size: 0.781rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.chip .avatar {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
color: var(--accent);
display: grid;
place-items: center;
}
.ms-item .ms-check svg {
width: 0.9375rem;
height: 0.9375rem;
.chip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
margin-left: 0.0625rem;
}
.ms-all {
/* 참석자 아바타(ScheduleAvatar :size="20" = 1.25rem)와 동일 크기 */
width: 1.25rem;
height: 1.25rem;
.chip .x:hover {
color: var(--red);
}
/* 전 직원 표식(드롭다운/칩 공용) */
.ad-all {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.5625rem;
font-size: 0.6875rem;
font-weight: 700;
color: #fff;
background: #9298a3;
flex-shrink: 0;
}
.ms-div {
height: 0.0625rem;
background: var(--border);
margin: 0.25rem 0.125rem;
}
.ms-empty {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
text-align: center;
.ad-all.sm {
width: 1.375rem;
height: 1.375rem;
font-size: 0.625rem;
}
</style>
+8 -3
View File
@@ -79,8 +79,13 @@ api.interceptors.request.use(
(error: unknown) => Promise.reject(error),
)
// 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외
const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/')
// 401 refresh-재시도에서 제외할 호출 — refresh/login 자체만 제외한다.
// (refresh 무한 루프·잘못된 자격증명 재시도 방지. 단 /auth/me 등은 access 만료 시
// refresh 후 재시도되어야 새로고침/재진입에도 세션이 복원된다)
const isRefreshExcluded = (url?: string): boolean => {
const u = url ?? ''
return u.includes('/auth/refresh') || u.includes('/auth/login')
}
// refresh 단일 실행(single-flight) — 동시 401 이 각자 refresh 를 호출하면 회전형 토큰이
// 재사용으로 오탐되어 세션이 폐기될 수 있다. 진행 중 refresh 가 있으면 그 Promise 를 공유한다.
@@ -120,7 +125,7 @@ api.interceptors.response.use(
// access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도
// (동시 401 은 single-flight 로 묶어 refresh 토큰 회전이 한 번만 일어나게 한다)
if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) {
if (status === 401 && config && !config._retry && !isRefreshExcluded(config.url)) {
config._retry = true
try {
await refreshOnce()
+20 -34
View File
@@ -29,6 +29,7 @@ import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useDialog } from '@/composables/useDialog'
import UserAvatar from '@/components/UserAvatar.vue'
import AvatarStack from '@/components/common/AvatarStack.vue'
import StatusSelect from '@/components/StatusSelect.vue'
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
import {
@@ -182,6 +183,15 @@ const assigneeNames = computed(() => {
if (names.length === 1) return names[0]
return `${names[0]}${names.length - 1}`
})
// 담당자 아바타 스택용 — 팝오버에 역할(role)을 보조문구로 표시
const assigneeStack = computed(() =>
(task.value?.assignees ?? []).map((a) => ({
id: a.id,
name: a.name,
avatarUrl: a.avatarUrl,
sub: a.role,
})),
)
// 수정 요청 모달 열기 — 모든 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청)
function openChanges() {
const checks: Record<string, boolean> = {}
@@ -1204,18 +1214,15 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
<div class="side-label">
담당자 · {{ task.assignees.length }}
</div>
<div class="assignees">
<div
v-for="a in task.assignees"
:key="a.id"
class="ass"
>
<span class="avatar"><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
<span><div class="nm">{{ a.name }}</div><div class="rl">{{ a.role }}</div></span>
</div>
<AvatarStack
v-if="task.assignees.length"
:people="assigneeStack"
/>
<div
v-else
class="ass-empty"
>
지정된 담당자가 없습니다.
</div>
</div>
<div class="side-field">
@@ -3154,30 +3161,9 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
.status-badge.done .dot {
background: var(--green);
}
.assignees {
display: flex;
flex-direction: column;
gap: 0.5625rem;
}
.ass {
display: flex;
align-items: center;
gap: 0.5625rem;
}
.ass .avatar {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
}
.ass .nm {
.ass-empty {
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.ass .rl {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.dl {
display: flex;