Refine vault defaults and account settings
This commit is contained in:
@@ -85,6 +85,8 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코
|
||||
|
||||
## 현재 전송·보관함 흐름
|
||||
|
||||
보관함은 받은 항목으로 먼저 열립니다. 계정 설정에서 이메일·비밀번호별 변경 버튼을 눌러 입력 화면으로 이동하며, 모바일에서는 하단 시트로 표시합니다. 기기 설정에서는 이름만 변경합니다. 사이트에 접속하면 자동 발견에 참여하며, 이전 버전에서 발견을 꺼둔 기기도 다시 접속하면 켜집니다. 파일의 실제 저장 위치·다운로드 실패·만료·관리자 접근은 [deploy.md의 파일 저장 안내](deploy.md#전송-파일이-저장되는-곳과-정리-시점)에 정리했습니다.
|
||||
|
||||
최신 동작 기준은 [TEMPORARY_VAULT.md](docs/TEMPORARY_VAULT.md)입니다. 중앙에서 기기를 고른 뒤 PC 모달·모바일 하단 시트로 링크·파일·임시 저장 자료를 보냅니다. 로그인 중 받은 자료는 보관함의 받은 항목에 날짜별로 남고, 직접 담은 자료는 임시 저장에서 한 줄 메모와 함께 관리합니다. 모든 자료는 최대 7일입니다. 저장 종류는 먼저 링크/파일 중 하나를 선택합니다. 이메일 원격 전송과 영구 즐겨찾기는 제거했습니다.
|
||||
|
||||
개발 실행은 같은 PC의 다른 브라우저를 자동 발견하고 반복 검증용 요청 한도를 사용합니다. QR 방에 들어가면 방 안의 기기만 보이며 한 방만 참여할 수 있습니다. 방장 나가기는 방 전체를 닫습니다. 운영 발견은 실제 프록시 경로를 검증한 후 켭니다.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Inbox,
|
||||
Send,
|
||||
LogOut,
|
||||
@@ -35,9 +36,9 @@ import {
|
||||
uploadProgress,
|
||||
clipboardTextLimitBytes,
|
||||
} from './client';
|
||||
const props = defineProps({ initialView: { type: String, default: 'vault' } });
|
||||
const props = defineProps({ initialView: { type: String, default: 'inbox' } });
|
||||
const emit = defineEmits(['devices', 'send-item']);
|
||||
const view = ref(props.initialView === 'inbox' ? 'inbox' : 'vault');
|
||||
const view = ref(props.initialView === 'vault' ? 'vault' : 'inbox');
|
||||
const authMode = ref('login'),
|
||||
email = ref(''),
|
||||
password = ref(''),
|
||||
@@ -66,6 +67,56 @@ const securityAction = ref('none'),
|
||||
newPassword = ref(''),
|
||||
newConfirmation = ref(''),
|
||||
newEmail = ref('');
|
||||
const preferencesDialog = ref(null);
|
||||
let preferencesTrigger;
|
||||
watch(preferencesOpen, async (open) => {
|
||||
if (open) {
|
||||
preferencesTrigger = document.activeElement;
|
||||
await nextTick();
|
||||
preferencesDialog.value
|
||||
?.querySelector('[aria-label="계정 설정 닫기"]')
|
||||
?.focus({ preventScroll: true });
|
||||
} else if (preferencesTrigger?.isConnected) {
|
||||
preferencesTrigger.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
watch(securityAction, async () => {
|
||||
if (!preferencesOpen.value) return;
|
||||
await nextTick();
|
||||
preferencesDialog.value
|
||||
?.querySelector('[aria-label="계정 설정 닫기"]')
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
function preferencesKeydown(event) {
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = [
|
||||
...preferencesDialog.value.querySelectorAll('button:not(:disabled), input:not(:disabled)'),
|
||||
];
|
||||
const first = controls[0],
|
||||
last = controls.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
function selectSecurityAction(action = 'none') {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = action;
|
||||
}
|
||||
function openPreferences() {
|
||||
selectSecurityAction();
|
||||
preferencesOpen.value = true;
|
||||
}
|
||||
function closePreferences() {
|
||||
preferencesOpen.value = false;
|
||||
selectSecurityAction();
|
||||
}
|
||||
let timer;
|
||||
function message(text) {
|
||||
notice.value = text;
|
||||
@@ -383,9 +434,7 @@ async function securitySubmit() {
|
||||
email: newEmail.value,
|
||||
password: currentPassword.value,
|
||||
});
|
||||
currentPassword.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = 'none';
|
||||
selectSecurityAction();
|
||||
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
|
||||
}
|
||||
});
|
||||
@@ -532,17 +581,9 @@ async function resendVerification() {
|
||||
<div class="account-heading compact-heading">
|
||||
<div>
|
||||
<h1>보관함</h1>
|
||||
<p>
|
||||
{{
|
||||
view === 'inbox'
|
||||
? '로그인 중 받은 링크와 파일을 날짜별로 모았어요.'
|
||||
: '나중에 보내거나 꺼내 쓸 링크와 파일을 담아두세요.'
|
||||
}}
|
||||
· 최대 7일
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="icon-button" @click="preferencesOpen = true" aria-label="계정 설정">
|
||||
<button class="icon-button" @click="openPreferences" aria-label="계정 설정">
|
||||
<Settings :size="18" /></button
|
||||
><button class="icon-button" @click="logout" aria-label="로그아웃">
|
||||
<LogOut :size="18" /></button
|
||||
@@ -672,7 +713,8 @@ async function resendVerification() {
|
||||
</div>
|
||||
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
|
||||
<p class="scope-note">
|
||||
내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 최대 7일
|
||||
내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 항목별 최대 7일
|
||||
보관
|
||||
</p>
|
||||
</section>
|
||||
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
|
||||
@@ -734,21 +776,76 @@ async function resendVerification() {
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="preferencesOpen" class="modal-backdrop" @click.self="preferencesOpen = false">
|
||||
<section class="modal" role="dialog" aria-modal="true" aria-label="계정 설정">
|
||||
<div
|
||||
v-if="preferencesOpen"
|
||||
class="modal-backdrop"
|
||||
@click.self="closePreferences"
|
||||
@keydown.esc.stop.prevent="closePreferences"
|
||||
>
|
||||
<section
|
||||
ref="preferencesDialog"
|
||||
class="modal account-preferences"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="계정 설정"
|
||||
@keydown="preferencesKeydown"
|
||||
>
|
||||
<header>
|
||||
<h2>계정 설정</h2>
|
||||
<button class="icon-button" @click="preferencesOpen = false" aria-label="계정 설정 닫기">
|
||||
<div class="preferences-title">
|
||||
<button
|
||||
v-if="securityAction !== 'none'"
|
||||
class="icon-button"
|
||||
@click="selectSecurityAction()"
|
||||
aria-label="계정 설정 목록으로"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{
|
||||
securityAction === 'password'
|
||||
? '비밀번호 변경'
|
||||
: securityAction === 'email'
|
||||
? '이메일 변경'
|
||||
: '계정 설정'
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="closePreferences" aria-label="계정 설정 닫기">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<p>{{ account.email }}</p>
|
||||
<select v-model="securityAction" aria-label="계정 정보 변경">
|
||||
<option value="none">변경할 항목 선택</option>
|
||||
<option value="password">비밀번호 변경</option>
|
||||
<option value="email">이메일 변경</option>
|
||||
</select>
|
||||
<form v-if="securityAction !== 'none'" @submit.prevent="securitySubmit">
|
||||
<div v-if="securityAction === 'none'" class="preferences-list">
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
<strong>이메일</strong>
|
||||
<p>{{ account.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button small"
|
||||
@click="selectSecurityAction('email')"
|
||||
aria-label="이메일 변경"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
<strong>비밀번호</strong>
|
||||
<p>계정을 안전하게 보호하세요.</p>
|
||||
</div>
|
||||
<button
|
||||
class="button small"
|
||||
@click="selectSecurityAction('password')"
|
||||
aria-label="비밀번호 변경"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form v-else class="preferences-form" @submit.prevent="securitySubmit">
|
||||
<p v-if="securityAction === 'email'" class="preferences-hint">
|
||||
새 이메일로 받은 인증을 완료하면 변경됩니다.
|
||||
</p>
|
||||
<label
|
||||
>현재 비밀번호<input
|
||||
type="password"
|
||||
@@ -770,8 +867,9 @@ async function resendVerification() {
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label></template
|
||||
><label v-else>새 이메일<input type="email" v-model="newEmail" required /></label
|
||||
><button class="button primary" :disabled="busy">변경</button>
|
||||
><label v-else
|
||||
>새 이메일<input type="email" v-model="newEmail" required autocomplete="email" /></label
|
||||
><button class="button primary" :disabled="busy">{{ busy ? '변경 중…' : '변경' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -902,13 +902,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<div v-if="!connectionCards.length" class="empty-devices">
|
||||
<Monitor :size="30" stroke-width="1.4" />
|
||||
<h3>아직 연결된 기기가 없어요.</h3>
|
||||
<p>
|
||||
{{
|
||||
device?.discoverable
|
||||
? '다른 기기에서 hop.을 열거나 QR로 연결해 보세요.'
|
||||
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
|
||||
}}
|
||||
</p>
|
||||
<p>다른 기기에서 hop.을 열거나 QR로 연결해 보세요.</p>
|
||||
</div>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
@@ -1042,10 +1036,7 @@ async function spaceAction(space, action, memberId) {
|
||||
</section>
|
||||
</template>
|
||||
<template v-else
|
||||
><AccountPanel
|
||||
:initial-view="tab === 'received' ? 'inbox' : 'vault'"
|
||||
@devices="tab = 'devices'"
|
||||
@send-item="prepareVault"
|
||||
><AccountPanel initial-view="inbox" @devices="tab = 'devices'" @send-item="prepareVault"
|
||||
/></template>
|
||||
</template>
|
||||
</main>
|
||||
@@ -1278,27 +1269,7 @@ async function spaceAction(space, action, memberId) {
|
||||
/> </label
|
||||
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
|
||||
</form>
|
||||
<label class="settings-row discovery-setting">
|
||||
<span>다른 기기에서 발견 허용</span>
|
||||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||||
<input
|
||||
class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
:checked="device?.discoverable"
|
||||
:disabled="busy"
|
||||
@change="act(() => patchDevice({ discoverable: $event.target.checked }))"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 rounded-full bg-[var(--line)] transition-colors motion-reduce:transition-none peer-checked:bg-[var(--ink)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-[var(--ink)] peer-disabled:opacity-50"
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0.5 top-0.5 size-5 rounded-full bg-[var(--surface)] shadow-sm transition-transform motion-reduce:transition-none peer-checked:translate-x-5 peer-disabled:opacity-50"
|
||||
></span>
|
||||
</span> </label
|
||||
></template>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,11 @@ export async function bootstrap() {
|
||||
device.value = s.device;
|
||||
csrfToken.value = s.csrfToken;
|
||||
sessionExpiresAt.value = s.expiresAt;
|
||||
// An open site participates in discovery, including sessions that opted out in older UI.
|
||||
if (!s.device.discoverable) {
|
||||
await api('/device/me', 'PATCH', { discoverable: true });
|
||||
device.value = { ...s.device, discoverable: true };
|
||||
}
|
||||
};
|
||||
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
|
||||
else await init();
|
||||
|
||||
@@ -3001,6 +3001,76 @@ a.button {
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.account-preferences header {
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.preferences-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.preferences-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.preference-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.preference-item > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.preference-item strong {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.preference-item p,
|
||||
.preferences-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.preference-item p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.preference-item .button {
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.preferences-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.preferences-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.preferences-form input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.preferences-form > .button {
|
||||
min-height: 44px;
|
||||
margin-top: 6px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.preferences-hint {
|
||||
margin: 0;
|
||||
}
|
||||
.device-settings-form label {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
|
||||
21
deploy.md
21
deploy.md
@@ -205,6 +205,27 @@ https://hop.noname.company
|
||||
|
||||
비로그인은 내역 화면이 없습니다. 파일은 서버로 업로드한 뒤 상대가 다운로드하므로, 전송과 정리를 위한 임시 서버 파일은 필요합니다. 로그인한 개인 보관함과 익명 전체 용량은 별도입니다. 직접 보관함 파일을 삭제하거나 만료되면 개인 사용량에서 빠지며, 다른 사람에게 공유된 원본은 마지막 유효 접근권이 끝날 때 물리적으로 삭제됩니다.
|
||||
|
||||
### 전송 파일이 저장되는 곳과 정리 시점
|
||||
|
||||
현재 흐름은 **수락 → 서버로 업로드 → NAS에 원본 저장 → 수신자가 다운로드 → 보관 기간 종료 후 정리**입니다. 다운로드 완료 직후 삭제하는 방식이 아닙니다. 임시 저장과 받은 항목도 같은 파일 저장소를 사용하며 DB의 접근 권한으로 사용자별 목록을 구분합니다.
|
||||
|
||||
| 데이터 | Docker 볼륨 | 컨테이너 내부 위치 |
|
||||
| --------------------------------- | ------------------ | --------------------------------------------------- |
|
||||
| 업로드 완료된 파일 원본 | `hop_hop_uploads` | `/data/uploads/originals/` |
|
||||
| 업로드 중인 파일·조각 | `hop_hop_uploads` | `/data/uploads/temporary/`, `/data/uploads/chunks/` |
|
||||
| 계정·파일명·소유자·만료·접근 권한 | `hop_hop_database` | `/data/database/hop.sqlite` |
|
||||
|
||||
원본 파일은 무작위 식별자로 저장합니다. 원래 파일명과 사용자 연결 정보는 DB에 있습니다. NAS의 실제 저장 경로는 아래 명령으로 확인합니다.
|
||||
|
||||
```sh
|
||||
sudo docker volume inspect hop_hop_uploads --format '{{.Mountpoint}}'
|
||||
```
|
||||
|
||||
- **다운로드 실패·수신자 이탈:** 이미 저장된 원본은 바로 삭제되지 않습니다. 받은 항목에 저장된 파일은 로그인 후 만료 전에 다시 다운로드할 수 있습니다. 개인 용량 초과로 받은 항목에 저장되지 않았거나 비로그인으로 받은 경우, 사이트를 떠난 뒤 이전 다운로드를 다시 찾는 UI는 제공하지 않습니다. 이때는 다시 전송하는 방법을 사용합니다. 다운로드 시작 표시는 기기에 저장이 끝났다는 확인을 의미하지 않습니다.
|
||||
- **업로드 도중 이탈:** 완료되지 않은 업로드는 받은 항목에 생성되지 않습니다. 취소된 조각은 제거하며, 남은 미완료·고아 업로드는 기본 24시간 후 정리합니다. 브라우저를 떠난 뒤 자동 전송·자동 다운로드 재개는 보장하지 않습니다.
|
||||
- **만료·삭제:** 기본 보관 기간은 항목별 7일이며, 정리 작업은 약 1분 간격입니다. 같은 원본을 사용하는 유효 접근 권한이 하나라도 남아 있으면 유지합니다. 모두 만료·삭제되면 삭제 실행 취소 시간 이후 원본을 제거합니다. 재배포·재시작만으로 파일이 삭제되지는 않습니다.
|
||||
- **관리자 접근:** NAS·Docker 볼륨과 DB를 읽을 권한이 있는 관리자는 파일과 연결 정보를 확인할 수 있습니다. 종단 간 암호화나 파일 자체 암호화는 적용하지 않습니다. 앱에는 관리자가 모든 사용자 파일을 열람하는 별도 화면이 없으며, 일반 계정은 허용된 본인 항목만 조회할 수 있습니다.
|
||||
|
||||
개인 한도가 차도 **수신·다운로드는 계속**합니다. 새로 받은 파일은 받은 항목에 저장하지 않고 짧은 안내를 표시합니다. 직접 저장은 용량 부족 시 거절합니다. 링크·메모는 파일 용량 합계에 포함하지 않습니다. 기존 10GiB 기본값은 설정 스크립트가 2GiB로 바꾸며, 다른 값으로 조정한 설정과 기존 자료는 유지합니다.
|
||||
|
||||
**대용량 파일:** hop.은 파일을 **8MiB씩 분할**해 업로드합니다. Cloudflare의 Free/Pro **100MB**, Business **200MB** 제한은 **HTTP 요청 한 번의 크기**이므로 전체 파일을 그 크기로 제한하지 않습니다. 앱의 파일당 최대 **2GiB**와 계정·익명 저장소 제한은 유지합니다. 중간 요청 오류는 서버가 받은 위치부터 최대 3회 자동 재시도합니다. 재시도가 끝나면 현재 전송 화면의 ‘다시 전송’을 누릅니다. 브라우저를 재시작하거나 로그아웃한 뒤의 자동 복구는 보장하지 않습니다. 마지막 파일 내용 검증과 저장이 끝나야 수신 자료가 생성됩니다. [Cloudflare 업로드 제한](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# hop. 기기 전송·임시 보관함 기준
|
||||
|
||||
2026-10-12 사용자 수정안을 반영한 현재 기준이다. 이전 SPEC의 이메일 원격 전송, 익명 전송 내역 화면, 영구 즐겨찾기, 여러 방 동시 참여 및 좌우 분할 화면보다 우선한다.
|
||||
2026-10-13 사용자 수정안을 반영한 현재 기준이다. 이전 SPEC의 이메일 원격 전송, 익명 전송 내역 화면, 영구 즐겨찾기, 여러 방 동시 참여 및 좌우 분할 화면보다 우선한다.
|
||||
|
||||
## 화면과 전송
|
||||
|
||||
메인은 중앙의 기기 선택 화면이다. 헤더는 홈으로 돌아가는 로고, 라이트/다크 토글, 로그인/보관함 버튼만 제공한다. 기기 목록은 최대 데스크톱 4열, 태블릿 3열, 모바일 2열이며 적은 기기는 가운데에 놓는다. 최대 화면 너비는 1080px이다. 내 기기는 자연스럽게 스크롤되는 푸터의 작은 상태 버튼으로 표시하고, 이름과 발견 허용을 그 버튼의 설정에서 변경한다. 발견 허용/꺼짐은 푸터에서 항상 확인할 수 있다. QR 연결은 기기 카드와 같은 영역의 보조 카드로 둔다. 헤더 로고와 짧은 보조 문구는 작게 표시하고 푸터의 서버 경유 안내는 제거한다.
|
||||
메인은 중앙의 기기 선택 화면이다. 헤더는 홈으로 돌아가는 로고, 로그인/보관함, 더보기 버튼을 제공한다. 앱 설치·테마·서비스 정보는 더보기 안에 둔다. 기기 목록은 최대 데스크톱 4열, 태블릿 3열, 모바일 2열이며 최대 화면 너비는 1080px이다. 내 기기는 자연스럽게 스크롤되는 푸터의 작은 상태 버튼으로 표시하고, 설정에서는 이름만 변경한다. 발견 허용 스위치는 표시하지 않으며 사이트 접속 시 자동 발견에 참여한다. 이전에 발견을 꺼둔 기기도 재접속 시 허용으로 바꾸고 기기 이름·세션은 유지한다. QR 연결은 기기 카드와 같은 영역의 보조 카드로 둔다. 헤더 로고와 짧은 보조 문구는 작게 표시하고 푸터의 서버 경유 안내는 제거한다.
|
||||
|
||||
기기를 누르면 PC 중앙 모달·모바일 하단 시트에서 현재 콘텐츠 하나를 보낸다. 클립보드 링크가 있으면 링크가 기본이며, 실제로 파일을 선택하면 링크를 대체한다. 링크 입력으로 전환했을 때 클립보드가 비어 있으면 빈 URL 입력과 클립보드 가져오기를 제공한다. 링크·파일·보관함 자료를 함께 보내지 않으며 전송/취소 버튼은 언제나 하나다. 선택 방법을 바꿔도 모달의 너비·높이를 유지하고 내용 영역 안에서만 스크롤한다. 기기 선택 전에는 전송 입력을 표시하지 않는다. 클립보드의 유효한 HTTP/HTTPS 링크만 제안하며 읽는 것만으로 전송하지 않는다. 다시 선택하거나 페이지로 돌아오면 최신 클립보드를 읽는다. 권한 제한 시 직접 새로고침·붙여넣기를 제공한다. 수동 편집 중인 URL은 자동 갱신으로 덮어쓰지 않는다. 파일 선택은 클립보드 실패와 독립적이다.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
비로그인 방문자에게 전송 내역을 보여주지 않는다. 로그인한 계정이 수락하고 그 세션이 전달 완료까지 유효한 경우 받은 항목에 기록한다. 로그인 전 받은 자료는 가져오지 않는다. 받은 항목은 날짜별로 묶는다. 자동 열기/다운로드 후 읽음은 기기 접근권과 계정 기록에 함께 반영한다.
|
||||
|
||||
오른쪽 보관함 버튼 안에서 `임시 저장 / 받은 항목`을 전환한다. 임시 저장은 자신이 링크·파일을 담는 공간이다. 먼저 `링크 저장 / 파일 저장` 중 하나를 선택하고 해당 입력만 표시한다. 선택적인 한 줄 제목/메모는 최대 200자이며 원래 만료를 연장하지 않고 수정할 수 있다. 이메일 주소로 다른 사람에게 보내는 새 요청은 제거했다. Resend는 가입·인증·비밀번호 복구 등 계정 메일에 계속 사용한다.
|
||||
오른쪽 보관함 버튼을 누르면 받은 항목을 먼저 표시하고 `임시 저장 / 받은 항목`을 전환할 수 있다. 임시 저장은 자신이 클립보드 콘텐츠·파일을 담는 공간이다. 먼저 `클립보드 저장 / 파일 저장` 중 하나를 선택하고 해당 입력만 표시한다. 선택적인 한 줄 제목/메모는 최대 200자이며 원래 만료를 연장하지 않고 수정할 수 있다. 제목 아래 보관 설명은 생략하고 하단 용량 안내와 함께 `항목별 최대 7일 보관`을 표시한다. 계정 설정은 이메일·비밀번호별 변경 버튼을 누른 뒤 해당 폼을 열며 모바일에서는 하단 시트로 표시한다. 이메일 주소로 다른 사람에게 보내는 새 요청은 제거했다. Resend는 가입·인증·비밀번호 복구 등 계정 메일에 계속 사용한다.
|
||||
|
||||
기기 전송은 새 링크, 새 파일, 로그인한 사용자의 보관함 자료 중에서 보낸다. 보관함은 임시 저장과 받은 항목을 포함한 세 번째 콘텐츠 선택 방법이며 비로그인 화면에서는 숨긴다. 항목 선택은 미리보기만 바꾸고 한 개의 전송 버튼으로 확정한다. 저장한 자료도 대상 기기가 수락한 뒤에 전달한다. 저장된 파일은 서버의 원본을 재사용해 브라우저 재다운로드·재업로드와 중복 물리 저장을 피한다. 원본의 개인 메모는 상대에게 복제하지 않는다. 다른 계정의 자료, 만료·삭제한 원본, 폐기된 발신 계정 세션으로는 보낼 수 없다.
|
||||
|
||||
|
||||
@@ -129,3 +129,11 @@
|
||||
- 새 두 브라우저 테스트에서 단일 URL·www 주소는 링크로 열고, 일반 문장·여러 줄·여러 URL·URL 포함 문장·장문은 텍스트 미리보기와 복사로 처리했다. 미리보기 스크롤·복사 피드백·HTML 이스케이프·입력 중 포커스/클립보드 변화 보존·명시적 다시 읽기·빈/거절된 클립보드·직접 입력·URL/텍스트 전환·크기 제한을 검증했다.
|
||||
- 초기 스켈레톤과 안내 문구 사이 간격, 기기명 현재/최대 40자 표시, 카드 텍스트 선택 방지와 모바일/미지원 브라우저에서 의도하지 않은 클립보드 읽기 방지를 검증했다. Safari의 네이티브 붙여넣기 확인은 공식 WebKit 동작을 참고했으며 실제 기기 확인은 운영 재배포 후 진행한다.
|
||||
- 기존 복수 URL API·수락 후 자동 탭 열기·파일 자동 다운로드·101MiB 조각 업로드·계정 보관함·방 승인/내보내기·PWA·위치 자동 발견의 회귀 검증을 통과했다. 운영 Docker 이미지 빌드와 재시작 뒤 원문 유니코드 텍스트·DB·파일·미완료 조각 업로드 보존 및 이어 보내기를 확인했다.
|
||||
|
||||
## 2026-10-13 보관함과 계정 설정 정리
|
||||
|
||||
- 보관함 진입·로그인 후 받은 항목을 기본 선택하고, 제목 아래 중복 안내를 제거했다. 하단 용량 안내에 항목별 최대 7일 보관을 표시한다.
|
||||
- 이메일·비밀번호 항목별 변경 버튼으로 해당 폼에 진입한다. PC 중앙 모달·모바일 하단 시트, 제목/입력 간격·가로 넘침 없음·모바일 16px 입력·자동 키보드 포커스 없음·뒤로가기/닫기 시 비밀 초안 제거·Escape/Tab 포커스 유지 등을 실제 Chromium 브라우저로 확인했다. 실제 휴대폰 Safari 키보드는 운영 재배포 후 확인한다.
|
||||
- 내 기기 설정의 발견 허용 스위치를 제거했다. 이전 버전에서 발견을 꺼둔 세션도 재접속 시 켜지며 기기 ID·저장한 이름을 유지하는지 검증했다. 기존 서버의 접근 제어·방 격리와 발견 차단 API는 유지한다.
|
||||
- 전체 빌드·타입 검사와 API 테스트 80개 통과. 계정 보관함·반응형 레이아웃·익명 파일/링크 전송·클립보드 텍스트·위치 자동 발견 브라우저 검증 5종을 통과했다. 실제 계정 메일은 테스트 메일러로 검증하며 외부 Resend 발송은 수행하지 않았다.
|
||||
- 파일 원본·조각·DB의 영구 볼륨 위치, 다운로드 실패·사이트 이탈, 마지막 유효 참조가 끝난 뒤 원본 정리와 관리자 접근 범위를 구현 코드로 확인해 deploy.md에 기록했다. 파일 보관 정책·DB 구조·YAML·환경변수는 변경하지 않았다. NAS 원격 배포는 수행하지 않았다.
|
||||
|
||||
@@ -300,12 +300,10 @@ try {
|
||||
.getByRole('button', { name: '저장', exact: true })
|
||||
.boundingBox();
|
||||
assert.equal(mobileInput.height, mobileSave.height);
|
||||
await page.getByRole('switch', { name: '다른 기기에서 발견 허용' }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('.discovery-setting')
|
||||
.evaluate((el) => getComputedStyle(el).borderBottomWidth),
|
||||
'0px',
|
||||
await settings.getByRole('switch', { name: '다른 기기에서 발견 허용' }).count(),
|
||||
0,
|
||||
'발견 허용 설정을 표시하지 않음',
|
||||
);
|
||||
const settingsBounds = await settings.boundingBox();
|
||||
assert(Math.abs(settingsBounds.y + settingsBounds.height - 900) < 2, '모바일 설정 하단 시트');
|
||||
@@ -381,12 +379,30 @@ try {
|
||||
assert.equal(await page.locator('.name-count').innerText(), '9/40');
|
||||
await page.getByRole('button', { name: '저장', exact: true }).click();
|
||||
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
|
||||
await page.locator('.footer-device-summary').click();
|
||||
await page.getByLabel('다른 기기에서 발견 허용').uncheck();
|
||||
const oldSession = await page.evaluate(async () => {
|
||||
const session = await (
|
||||
await fetch('/api/guest/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
).json();
|
||||
const response = await fetch('/api/device/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrfToken },
|
||||
body: JSON.stringify({ discoverable: false }),
|
||||
});
|
||||
return { deviceId: session.device.id, ok: response.ok };
|
||||
});
|
||||
assert.equal(oldSession.ok, true);
|
||||
await page.locator('.footer-device-summary').filter({ hasText: '발견 꺼짐' }).waitFor();
|
||||
await page.getByLabel('다른 기기에서 발견 허용').check();
|
||||
await page.reload();
|
||||
await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
|
||||
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
|
||||
assert.equal(await page.locator('.footer-device-name').innerText(), '나의 테스트 기기');
|
||||
assert.equal(
|
||||
await page.evaluate(async () => (await (await fetch('/api/device/snapshot')).json()).device.id),
|
||||
oldSession.deviceId,
|
||||
);
|
||||
await page.getByRole('button', { name: '다른 기기 연결', exact: true }).click();
|
||||
assert.equal(await page.locator('.connection-choice').count(), 2);
|
||||
assert.equal(await page.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).count(), 0);
|
||||
@@ -436,7 +452,7 @@ try {
|
||||
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 실제 12대 자동 발견, 4/3/2열과 줄바꿈, 푸터의 내 기기 상태·이름/발견 설정, 선택 전 전송 UI 없음, 고정 크기 PC 모달·모바일 하단 시트, 하나의 전송 버튼과 파일/링크 대체, Escape, QR 참여 시 모달 자동 닫힘, 방 안의 기기 격리·나가기 후 주변 복귀.',
|
||||
'PASS: 실제 12대 자동 발견, 4/3/2열과 줄바꿈, 푸터의 기기명 설정·발견 옵션 숨김·이전 비공개 세션의 재접속 후 발견 복구, 선택 전 전송 UI 없음, 고정 크기 PC 모달·모바일 하단 시트, 하나의 전송 버튼과 파일/링크 대체, Escape, QR 참여 시 모달 자동 닫힘, 방 안의 기기 격리·나가기 후 주변 복귀.',
|
||||
);
|
||||
} finally {
|
||||
for (const socket of sockets) socket.disconnect();
|
||||
|
||||
@@ -80,10 +80,95 @@ try {
|
||||
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
|
||||
await auth.getByRole('button', { name: '로그인', exact: true }).click();
|
||||
await page.locator('.account-workspace').waitFor();
|
||||
assert.equal(
|
||||
await page.getByRole('tab', { name: '받은 항목', exact: true }).getAttribute('aria-selected'),
|
||||
'true',
|
||||
'보관함 기본 화면은 받은 항목',
|
||||
);
|
||||
assert.equal(await page.locator('.account-heading p').count(), 0);
|
||||
await page.locator('.scope-note').filter({ hasText: '항목별 최대 7일 보관' }).waitFor();
|
||||
return await page.locator('.footer-device-name').innerText();
|
||||
}
|
||||
const senderName = await register(sender, 'alice@example.com', 'Alice');
|
||||
await register(receiver, 'bob@example.com', 'Bob');
|
||||
for (const [page, email, device] of [
|
||||
[sender, 'alice@example.com', 'desktop'],
|
||||
[receiver, 'bob@example.com', 'mobile'],
|
||||
]) {
|
||||
await page.getByRole('button', { name: '계정 설정', exact: true }).click();
|
||||
const preferences = page.getByRole('dialog', { name: '계정 설정', exact: true });
|
||||
await preferences.waitFor();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
assert.equal(
|
||||
await preferences
|
||||
.getByRole('button', { name: '비밀번호 변경', exact: true })
|
||||
.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
'포커스가 모달 밖으로 나가지 않음',
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await preferences.locator('select,input').count(), 0, '항목 목록을 먼저 표시');
|
||||
assert.equal(await preferences.locator('.preference-item').count(), 2);
|
||||
assert.equal(
|
||||
await preferences.locator('.preference-item').first().locator('p').innerText(),
|
||||
email,
|
||||
);
|
||||
const viewport = page.viewportSize();
|
||||
const bounds = await preferences.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.x + bounds.width <= viewport.width);
|
||||
if (device === 'mobile')
|
||||
assert(
|
||||
Math.abs(bounds.y + bounds.height - viewport.height) < 2,
|
||||
'계정 설정은 모바일 하단 시트',
|
||||
);
|
||||
await page.screenshot({ path: `tests/e2e/account-settings-${device}.png` });
|
||||
await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click();
|
||||
await preferences.getByLabel('현재 비밀번호', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await preferences
|
||||
.getByLabel('현재 비밀번호', { exact: true })
|
||||
.evaluate((el) => document.activeElement === el),
|
||||
false,
|
||||
'입력창 자동 포커스 없음',
|
||||
);
|
||||
const headerBounds = await preferences.locator('header').boundingBox();
|
||||
const formBounds = await preferences.locator('form').boundingBox();
|
||||
assert(formBounds.y >= headerBounds.y + headerBounds.height + 16, '제목과 변경 폼 간격');
|
||||
for (const field of ['현재 비밀번호', '새 비밀번호', '새 비밀번호 확인']) {
|
||||
const input = preferences.getByLabel(field, { exact: true });
|
||||
if (device === 'mobile')
|
||||
assert.equal(await input.evaluate((el) => getComputedStyle(el).fontSize), '16px');
|
||||
await input.fill('draft1234');
|
||||
const inputBounds = await input.boundingBox();
|
||||
assert(
|
||||
inputBounds.x >= bounds.x && inputBounds.x + inputBounds.width <= bounds.x + bounds.width,
|
||||
);
|
||||
}
|
||||
await page.screenshot({ path: `tests/e2e/account-password-${device}.png` });
|
||||
await preferences.getByRole('button', { name: '계정 설정 목록으로', exact: true }).click();
|
||||
await preferences.getByRole('button', { name: '이메일 변경', exact: true }).click();
|
||||
assert.equal(
|
||||
await preferences.getByLabel('현재 비밀번호', { exact: true }).inputValue(),
|
||||
'',
|
||||
'항목 전환 시 비밀번호 초안 제거',
|
||||
);
|
||||
await preferences.getByLabel('새 이메일', { exact: true }).fill('new@example.com');
|
||||
await page.screenshot({ path: `tests/e2e/account-email-${device}.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
await preferences.waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: '계정 설정', exact: true }).click();
|
||||
await preferences.getByRole('button', { name: '이메일 변경', exact: true }).click();
|
||||
assert.equal(
|
||||
await preferences.getByLabel('새 이메일', { exact: true }).inputValue(),
|
||||
'',
|
||||
'모달 닫기 시 개인정보 초안 제거',
|
||||
);
|
||||
await preferences.getByRole('button', { name: '계정 설정 닫기', exact: true }).click();
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
}
|
||||
async function navigate(page, view) {
|
||||
if (view === '기기') return page.getByRole('button', { name: 'hop. 홈', exact: true }).click();
|
||||
await page.locator('.login-button').click();
|
||||
@@ -220,7 +305,7 @@ try {
|
||||
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 1234 가입·인증·로그인, 로그인 기기 수신의 날짜별 기록, 직접 링크/파일 저장과 메모 수정, 보관함 자료의 수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료, 삭제/복원, 로그아웃 권한 격리, 모바일 화면. 실제 Resend 발송은 포함하지 않음.',
|
||||
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정 목록·항목별 폼·간격·16px 입력·초안 제거·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장과 메모 수정, 보관함 자료의 수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료, 삭제/복원, 로그아웃 권한 격리. 실제 Resend 발송은 포함하지 않음.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
Reference in New Issue
Block a user