feat: refine navigation and account access flow
This commit is contained in:
+1
-1
@@ -5,4 +5,4 @@ node_modules/
|
|||||||
data/images/*
|
data/images/*
|
||||||
!data/images/.gitkeep
|
!data/images/.gitkeep
|
||||||
coverage/
|
coverage/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
+24
-10
@@ -1,6 +1,8 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name recipe.example.com;
|
listen [::]:80;
|
||||||
|
|
||||||
|
server_name recipeatlas.horoli.kr;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
root /var/www/certbot;
|
root /var/www/certbot;
|
||||||
@@ -12,25 +14,37 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl;
|
||||||
server_name recipe.example.com;
|
listen [::]:443 ssl http2;
|
||||||
|
|
||||||
# 실제 인증서 경로로 교체한다. Let's Encrypt/Caddy를 사용해도 된다.
|
server_name recipeatlas.horoli.kr;
|
||||||
ssl_certificate /etc/letsencrypt/live/recipe.example.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/recipe.example.com/privkey.pem;
|
ssl_certificate /etc/letsencrypt/live/horoli.kr/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/horoli.kr/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
client_max_body_size 2m;
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/recipeatlas.horoli.kr.access.log;
|
||||||
|
error_log /var/log/nginx/recipeatlas.horoli.kr.error.log;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://192.168.0.250:1771;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_read_timeout 120s;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 360s;
|
||||||
|
proxy_read_timeout 360s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -81,6 +81,7 @@ img { display: block; max-width: 100%; }
|
|||||||
|
|
||||||
.profile { display: flex; align-items: center; gap: 12px; font-size: 0.86rem; }
|
.profile { display: flex; align-items: center; gap: 12px; font-size: 0.86rem; }
|
||||||
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
|
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
|
||||||
|
.profile .avatar[hidden] { display: none; }
|
||||||
.menu-button { display: grid; width: 42px; height: 42px; padding: 10px; place-content: center; gap: 4px; border: 1px solid var(--line); border-radius: 50%; cursor: pointer; background: transparent; }
|
.menu-button { display: grid; width: 42px; height: 42px; padding: 10px; place-content: center; gap: 4px; border: 1px solid var(--line); border-radius: 50%; cursor: pointer; background: transparent; }
|
||||||
.menu-button[hidden] { display: none; }
|
.menu-button[hidden] { display: none; }
|
||||||
.menu-button span { display: block; width: 18px; height: 2px; border-radius: 2px; background: var(--ink); }
|
.menu-button span { display: block; width: 18px; height: 2px; border-radius: 2px; background: var(--ink); }
|
||||||
@@ -108,7 +109,7 @@ img { display: block; max-width: 100%; }
|
|||||||
.button-quiet { min-height: 38px; padding: 7px 14px; border-color: var(--line); background: transparent; }
|
.button-quiet { min-height: 38px; padding: 7px 14px; border-color: var(--line); background: transparent; }
|
||||||
.button-danger { border-color: #a43928; color: #8d3022; background: transparent; }
|
.button-danger { border-color: #a43928; color: #8d3022; background: transparent; }
|
||||||
.google-login { width: 100%; gap: 12px; }
|
.google-login { width: 100%; gap: 12px; }
|
||||||
.google-login span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; color: var(--green-dark); background: white; }
|
.google-login > span[aria-hidden] { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; color: var(--green-dark); background: white; }
|
||||||
|
|
||||||
.page-shell { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 100px; }
|
.page-shell { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 100px; }
|
||||||
|
|
||||||
@@ -239,6 +240,9 @@ img { display: block; max-width: 100%; }
|
|||||||
.login-card .brand-mark { margin: 0 auto 28px; }
|
.login-card .brand-mark { margin: 0 auto 28px; }
|
||||||
.login-card h1 { margin: 8px 0 16px; font-family: var(--serif); font-size: 3rem; font-weight: 400; line-height: 1; }
|
.login-card h1 { margin: 8px 0 16px; font-family: var(--serif); font-size: 3rem; font-weight: 400; line-height: 1; }
|
||||||
.login-copy { margin: 0 0 32px; color: var(--muted); }
|
.login-copy { margin: 0 0 32px; color: var(--muted); }
|
||||||
|
.login-alert { margin: -12px 0 20px; padding: 14px 16px; border: 1px solid rgba(164, 57, 40, 0.3); border-radius: 14px; color: #7f2e22; background: #fff3ed; text-align: left; }
|
||||||
|
.login-alert strong { font-size: 0.9rem; }
|
||||||
|
.login-alert p { margin: 5px 0 0; font-size: 0.76rem; line-height: 1.5; }
|
||||||
.fine-print { margin: 18px 0 0; color: var(--muted); font-size: 0.72rem; }
|
.fine-print { margin: 18px 0 0; color: var(--muted); font-size: 0.72rem; }
|
||||||
|
|
||||||
.detail-shell { width: min(1160px, calc(100% - 40px)); margin: 0 auto; padding: 50px 0 100px; }
|
.detail-shell { width: min(1160px, calc(100% - 40px)); margin: 0 auto; padding: 50px 0 100px; }
|
||||||
@@ -300,11 +304,12 @@ img { display: block; max-width: 100%; }
|
|||||||
.import-form { flex-direction: column; border-radius: 18px; }
|
.import-form { flex-direction: column; border-radius: 18px; }
|
||||||
.import-form .button { width: 100%; }
|
.import-form .button { width: 100%; }
|
||||||
.recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
.recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
.recipe-card { border-radius: 14px; }
|
.recipe-card { display: flex; overflow: hidden; flex-direction: column; border-radius: 14px; }
|
||||||
.recipe-card-body { padding: 14px; }
|
.recipe-card-body { display: flex; height: 228px; flex: 0 0 228px; flex-direction: column; overflow: hidden; padding: 14px; }
|
||||||
.recipe-card-body h3 { display: -webkit-box; overflow: hidden; margin: 10px 0 6px; font-size: 1.15rem; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
.recipe-card-body h3 { display: -webkit-box; overflow: hidden; min-height: 2.58rem; max-height: 2.58rem; margin: 10px 0 6px; font-size: 1.15rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||||
.recipe-card-body > p:not(.recipe-card-match) { display: -webkit-box; overflow: hidden; font-size: 0.75rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
.recipe-card-body > p:not(.recipe-card-match) { display: -webkit-box; overflow: hidden; min-height: 2.18rem; max-height: 2.18rem; font-size: 0.75rem; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||||
.recipe-card-tags { gap: 4px; margin-top: 10px; }
|
.recipe-card-body .recipe-card-match { display: -webkit-box; overflow: hidden; max-height: 2.5rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||||
|
.recipe-card-tags { overflow: hidden; max-height: 3.4rem; gap: 4px; margin-top: auto; padding-top: 10px; }
|
||||||
.recipe-card-tags .tag { font-size: 0.68rem; }
|
.recipe-card-tags .tag { font-size: 0.68rem; }
|
||||||
.tag-filter-heading { align-items: flex-start; flex-direction: column; }
|
.tag-filter-heading { align-items: flex-start; flex-direction: column; }
|
||||||
.tag-filter-heading small { display: block; margin: 2px 0 0; }
|
.tag-filter-heading small { display: block; margin: 2px 0 0; }
|
||||||
|
|||||||
+1
-41
@@ -8,21 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/app.css">
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header id="site-header" class="site-header"></header>
|
||||||
<a class="brand" href="/" aria-label="Our Recipe Atlas 홈">
|
|
||||||
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
|
||||||
<span>Our Recipe Atlas</span>
|
|
||||||
</a>
|
|
||||||
<div class="profile">
|
|
||||||
<img id="profile-image" class="avatar" alt="" hidden>
|
|
||||||
<span id="profile-name"></span>
|
|
||||||
<button id="access-menu-button" class="menu-button" type="button"
|
|
||||||
aria-label="메뉴 열기" aria-controls="access-drawer"
|
|
||||||
aria-expanded="false">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="page-shell">
|
<main class="page-shell">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -94,32 +80,6 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<button id="drawer-backdrop" class="drawer-backdrop" type="button"
|
|
||||||
aria-label="메뉴 닫기" hidden></button>
|
|
||||||
<aside id="access-drawer" class="access-drawer" role="dialog" aria-modal="true"
|
|
||||||
aria-labelledby="drawer-title" aria-hidden="true" hidden>
|
|
||||||
<div class="drawer-heading">
|
|
||||||
<div>
|
|
||||||
<p class="section-number">MENU</p>
|
|
||||||
<h2 id="drawer-title">메뉴</h2>
|
|
||||||
</div>
|
|
||||||
<button id="close-access-drawer" class="drawer-close" type="button"
|
|
||||||
aria-label="메뉴 닫기">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-account">
|
|
||||||
<strong id="drawer-user-name"></strong>
|
|
||||||
<span id="drawer-user-email"></span>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<button id="open-access-overlay" class="drawer-action" type="button" hidden>
|
|
||||||
<span>로그인 허용 계정 관리</span><small>관리자</small>
|
|
||||||
</button>
|
|
||||||
<button id="drawer-logout" class="drawer-action danger" type="button" data-logout>
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<button id="access-overlay-backdrop" class="access-overlay-backdrop" type="button"
|
<button id="access-overlay-backdrop" class="access-overlay-backdrop" type="button"
|
||||||
aria-label="로그인 허용 계정 관리 닫기" hidden></button>
|
aria-label="로그인 허용 계정 관리 닫기" hidden></button>
|
||||||
<section id="access-overlay" class="access-overlay" role="dialog" aria-modal="true"
|
<section id="access-overlay" class="access-overlay" role="dialog" aria-modal="true"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export function mountAppBar({ onManageAccess } = {}) {
|
||||||
|
const header = document.querySelector('#site-header');
|
||||||
|
if (!header) throw new Error('Global app bar mount point is missing.');
|
||||||
|
|
||||||
|
header.innerHTML = `
|
||||||
|
<a class="brand" href="/" aria-label="Our Recipe Atlas 홈">
|
||||||
|
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
||||||
|
<span>Our Recipe Atlas</span>
|
||||||
|
</a>
|
||||||
|
<div class="profile">
|
||||||
|
<img id="profile-image" class="avatar" alt="" hidden>
|
||||||
|
<span id="profile-name"></span>
|
||||||
|
<button id="access-menu-button" class="menu-button" type="button"
|
||||||
|
aria-label="메뉴 열기" aria-controls="access-drawer"
|
||||||
|
aria-expanded="false">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
header.insertAdjacentHTML('afterend', `
|
||||||
|
<button id="drawer-backdrop" class="drawer-backdrop" type="button"
|
||||||
|
aria-label="메뉴 닫기" hidden></button>
|
||||||
|
<aside id="access-drawer" class="access-drawer" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="drawer-title" aria-hidden="true" hidden>
|
||||||
|
<div class="drawer-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-number">MENU</p>
|
||||||
|
<h2 id="drawer-title">메뉴</h2>
|
||||||
|
</div>
|
||||||
|
<button id="close-access-drawer" class="drawer-close" type="button"
|
||||||
|
aria-label="메뉴 닫기">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-account">
|
||||||
|
<strong id="drawer-user-name"></strong>
|
||||||
|
<span id="drawer-user-email"></span>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<button id="open-access-overlay" class="drawer-action" type="button" hidden>
|
||||||
|
<span>로그인 허용 계정 관리</span><small>관리자</small>
|
||||||
|
</button>
|
||||||
|
<button id="drawer-logout" class="drawer-action danger" type="button" data-logout>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>`);
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
menuButton: document.querySelector('#access-menu-button'),
|
||||||
|
profileImage: document.querySelector('#profile-image'),
|
||||||
|
profileName: document.querySelector('#profile-name'),
|
||||||
|
drawer: document.querySelector('#access-drawer'),
|
||||||
|
closeButton: document.querySelector('#close-access-drawer'),
|
||||||
|
backdrop: document.querySelector('#drawer-backdrop'),
|
||||||
|
manageAccessButton: document.querySelector('#open-access-overlay'),
|
||||||
|
drawerUserName: document.querySelector('#drawer-user-name'),
|
||||||
|
drawerUserEmail: document.querySelector('#drawer-user-email'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function setOpen(open) {
|
||||||
|
elements.menuButton.setAttribute('aria-expanded', String(open));
|
||||||
|
elements.drawer.setAttribute('aria-hidden', String(!open));
|
||||||
|
elements.drawer.hidden = !open;
|
||||||
|
elements.backdrop.hidden = !open;
|
||||||
|
document.body.classList.toggle('drawer-open', open);
|
||||||
|
if (open) elements.closeButton.focus();
|
||||||
|
else elements.menuButton.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.menuButton.addEventListener('click', () => setOpen(true));
|
||||||
|
elements.closeButton.addEventListener('click', () => setOpen(false));
|
||||||
|
elements.backdrop.addEventListener('click', () => setOpen(false));
|
||||||
|
elements.manageAccessButton.addEventListener('click', () => {
|
||||||
|
setOpen(false);
|
||||||
|
if (onManageAccess) onManageAccess();
|
||||||
|
else window.location.assign('/?manage-access=1');
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && !elements.drawer.hidden) setOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusMenuButton() {
|
||||||
|
elements.menuButton.focus();
|
||||||
|
},
|
||||||
|
setUser(user) {
|
||||||
|
elements.profileName.textContent = user.name || user.email;
|
||||||
|
if (user.picture) {
|
||||||
|
elements.profileImage.src = user.picture;
|
||||||
|
elements.profileImage.alt = `${user.name || user.email} 프로필`;
|
||||||
|
elements.profileImage.hidden = false;
|
||||||
|
}
|
||||||
|
elements.drawerUserName.textContent = user.name || user.email;
|
||||||
|
elements.drawerUserEmail.textContent = user.email;
|
||||||
|
elements.manageAccessButton.hidden = !user.canManageAccess;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+20
-43
@@ -1,8 +1,11 @@
|
|||||||
import { apiRequest } from './api.js';
|
import { apiRequest } from './api.js';
|
||||||
|
import { mountAppBar } from './app-bar.js';
|
||||||
import { bindLogout, loadSession } from './auth.js';
|
import { bindLogout, loadSession } from './auth.js';
|
||||||
import { searchRecipes } from './recipe-search.js';
|
import { searchRecipes } from './recipe-search.js';
|
||||||
import { bindScrollToTop } from './scroll-to-top.js';
|
import { bindScrollToTop } from './scroll-to-top.js';
|
||||||
|
|
||||||
|
const appBar = mountAppBar({ onManageAccess: openAccessManager });
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
importForm: document.querySelector('#import-form'),
|
importForm: document.querySelector('#import-form'),
|
||||||
importStatus: document.querySelector('#import-status'),
|
importStatus: document.querySelector('#import-status'),
|
||||||
@@ -17,13 +20,6 @@ const elements = {
|
|||||||
tagFilterOptions: document.querySelector('#tag-filter-options'),
|
tagFilterOptions: document.querySelector('#tag-filter-options'),
|
||||||
toggleTagFilter: document.querySelector('#toggle-tag-filter'),
|
toggleTagFilter: document.querySelector('#toggle-tag-filter'),
|
||||||
clearTagFilter: document.querySelector('#clear-tag-filter'),
|
clearTagFilter: document.querySelector('#clear-tag-filter'),
|
||||||
accessMenuButton: document.querySelector('#access-menu-button'),
|
|
||||||
accessDrawer: document.querySelector('#access-drawer'),
|
|
||||||
closeAccessDrawer: document.querySelector('#close-access-drawer'),
|
|
||||||
drawerBackdrop: document.querySelector('#drawer-backdrop'),
|
|
||||||
drawerUserName: document.querySelector('#drawer-user-name'),
|
|
||||||
drawerUserEmail: document.querySelector('#drawer-user-email'),
|
|
||||||
openAccessOverlay: document.querySelector('#open-access-overlay'),
|
|
||||||
accessOverlay: document.querySelector('#access-overlay'),
|
accessOverlay: document.querySelector('#access-overlay'),
|
||||||
closeAccessOverlay: document.querySelector('#close-access-overlay'),
|
closeAccessOverlay: document.querySelector('#close-access-overlay'),
|
||||||
accessOverlayBackdrop: document.querySelector('#access-overlay-backdrop'),
|
accessOverlayBackdrop: document.querySelector('#access-overlay-backdrop'),
|
||||||
@@ -199,20 +195,7 @@ async function loadAllowedEmails() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePageLock() {
|
function updatePageLock() {
|
||||||
document.body.classList.toggle(
|
document.body.classList.toggle('drawer-open', !elements.accessOverlay.hidden);
|
||||||
'drawer-open',
|
|
||||||
!elements.accessDrawer.hidden || !elements.accessOverlay.hidden,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAccessDrawerOpen(open) {
|
|
||||||
elements.accessMenuButton.setAttribute('aria-expanded', String(open));
|
|
||||||
elements.accessDrawer.setAttribute('aria-hidden', String(!open));
|
|
||||||
elements.accessDrawer.hidden = !open;
|
|
||||||
elements.drawerBackdrop.hidden = !open;
|
|
||||||
updatePageLock();
|
|
||||||
if (open) elements.closeAccessDrawer.focus();
|
|
||||||
else elements.accessMenuButton.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAccessOverlayOpen(open) {
|
function setAccessOverlayOpen(open) {
|
||||||
@@ -221,7 +204,16 @@ function setAccessOverlayOpen(open) {
|
|||||||
elements.accessOverlayBackdrop.hidden = !open;
|
elements.accessOverlayBackdrop.hidden = !open;
|
||||||
updatePageLock();
|
updatePageLock();
|
||||||
if (open) elements.accessEmail.focus();
|
if (open) elements.accessEmail.focus();
|
||||||
else elements.accessMenuButton.focus();
|
else appBar.focusMenuButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAccessManager() {
|
||||||
|
setAccessOverlayOpen(true);
|
||||||
|
try {
|
||||||
|
await loadAllowedEmails();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ingredientGroupTemplate(group, groupIndex) {
|
function ingredientGroupTemplate(group, groupIndex) {
|
||||||
@@ -444,28 +436,11 @@ elements.clearRecipeSearch.addEventListener('click', () => {
|
|||||||
renderRecipes();
|
renderRecipes();
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.accessMenuButton.addEventListener('click', () => {
|
|
||||||
setAccessDrawerOpen(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.openAccessOverlay.addEventListener('click', async () => {
|
|
||||||
setAccessDrawerOpen(false);
|
|
||||||
setAccessOverlayOpen(true);
|
|
||||||
try {
|
|
||||||
await loadAllowedEmails();
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.closeAccessDrawer.addEventListener('click', () => setAccessDrawerOpen(false));
|
|
||||||
elements.drawerBackdrop.addEventListener('click', () => setAccessDrawerOpen(false));
|
|
||||||
elements.closeAccessOverlay.addEventListener('click', () => setAccessOverlayOpen(false));
|
elements.closeAccessOverlay.addEventListener('click', () => setAccessOverlayOpen(false));
|
||||||
elements.accessOverlayBackdrop.addEventListener('click', () => setAccessOverlayOpen(false));
|
elements.accessOverlayBackdrop.addEventListener('click', () => setAccessOverlayOpen(false));
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key !== 'Escape') return;
|
if (event.key !== 'Escape') return;
|
||||||
if (!elements.accessOverlay.hidden) setAccessOverlayOpen(false);
|
if (!elements.accessOverlay.hidden) setAccessOverlayOpen(false);
|
||||||
else if (!elements.accessDrawer.hidden) setAccessDrawerOpen(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.accessForm.addEventListener('submit', async (event) => {
|
elements.accessForm.addEventListener('submit', async (event) => {
|
||||||
@@ -507,9 +482,11 @@ elements.allowedEmailList.addEventListener('click', async (event) => {
|
|||||||
bindLogout();
|
bindLogout();
|
||||||
bindScrollToTop(elements.scrollToTop);
|
bindScrollToTop(elements.scrollToTop);
|
||||||
currentUser = await loadSession();
|
currentUser = await loadSession();
|
||||||
elements.drawerUserName.textContent = currentUser.name || currentUser.email;
|
appBar.setUser(currentUser);
|
||||||
elements.drawerUserEmail.textContent = currentUser.email;
|
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
if (currentUser.canManageAccess) {
|
if (
|
||||||
elements.openAccessOverlay.hidden = false;
|
currentUser.canManageAccess
|
||||||
|
&& new URLSearchParams(window.location.search).get('manage-access') === '1'
|
||||||
|
) {
|
||||||
|
await openAccessManager();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const error = new URLSearchParams(window.location.search).get('error');
|
||||||
|
|
||||||
|
if (error === 'account-not-allowed') {
|
||||||
|
document.querySelector('#login-alert').hidden = false;
|
||||||
|
document.querySelector('#google-login').href = '/auth/google/select-account';
|
||||||
|
document.querySelector('#google-login-label').textContent = '다른 Google 계정으로 다시 시도';
|
||||||
|
}
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
import { apiRequest } from './api.js';
|
import { apiRequest } from './api.js';
|
||||||
|
import { mountAppBar } from './app-bar.js';
|
||||||
import { bindLogout, loadSession } from './auth.js';
|
import { bindLogout, loadSession } from './auth.js';
|
||||||
import { bindScrollToTop } from './scroll-to-top.js';
|
import { bindScrollToTop } from './scroll-to-top.js';
|
||||||
|
|
||||||
|
const appBar = mountAppBar();
|
||||||
const detail = document.querySelector('#recipe-detail');
|
const detail = document.querySelector('#recipe-detail');
|
||||||
const toast = document.querySelector('#toast');
|
const toast = document.querySelector('#toast');
|
||||||
const scrollToTop = document.querySelector('#scroll-to-top');
|
const scrollToTop = document.querySelector('#scroll-to-top');
|
||||||
@@ -221,7 +223,7 @@ function renderRecipe(recipe) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindLogout();
|
bindLogout();
|
||||||
await loadSession();
|
appBar.setUser(await loadSession());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
|
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
|
||||||
|
|||||||
+7
-3
@@ -13,12 +13,16 @@
|
|||||||
<p class="eyebrow">PRIVATE RECIPE ARCHIVE</p>
|
<p class="eyebrow">PRIVATE RECIPE ARCHIVE</p>
|
||||||
<h1>Our Recipe Atlas</h1>
|
<h1>Our Recipe Atlas</h1>
|
||||||
<p class="login-copy">Instagram과 YouTube에서 발견한 레시피를 한곳에 정리하세요.</p>
|
<p class="login-copy">Instagram과 YouTube에서 발견한 레시피를 한곳에 정리하세요.</p>
|
||||||
<a class="button button-primary google-login" href="/auth/google">
|
<div id="login-alert" class="login-alert" role="alert" hidden>
|
||||||
|
<strong>이 Google 계정은 사용할 수 없어요.</strong>
|
||||||
|
<p>관리자에게 계정 등록을 요청하거나, 허용된 다른 계정으로 다시 시도해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
<a id="google-login" class="button button-primary google-login" href="/auth/google">
|
||||||
<span aria-hidden="true">G</span>
|
<span aria-hidden="true">G</span>
|
||||||
Google 계정으로 계속하기
|
<span id="google-login-label">Google 계정으로 계속하기</span>
|
||||||
</a>
|
</a>
|
||||||
<p class="fine-print">허용 목록에 등록된 Google 계정만 로그인할 수 있습니다.</p>
|
<p class="fine-print">허용 목록에 등록된 Google 계정만 로그인할 수 있습니다.</p>
|
||||||
</main>
|
</main>
|
||||||
|
<script type="module" src="/js/login.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+1
-11
@@ -7,17 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/css/app.css">
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header id="site-header" class="site-header"></header>
|
||||||
<a class="brand" href="/">
|
|
||||||
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
|
||||||
<span>Our Recipe Atlas</span>
|
|
||||||
</a>
|
|
||||||
<div class="profile">
|
|
||||||
<img id="profile-image" class="avatar" alt="" hidden>
|
|
||||||
<span id="profile-name"></span>
|
|
||||||
<button id="logout-button" class="button button-quiet" type="button" data-logout>로그아웃</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main id="recipe-detail" class="detail-shell">
|
<main id="recipe-detail" class="detail-shell">
|
||||||
<p class="empty-state">레시피를 불러오는 중입니다.</p>
|
<p class="empty-state">레시피를 불러오는 중입니다.</p>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+8
-4
@@ -93,6 +93,13 @@ async function authPlugin(fastify, { config, googleClient } = {}) {
|
|||||||
path: '/',
|
path: '/',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get('/auth/google/select-account', async (request, reply) => {
|
||||||
|
const authorizationUri = await fastify.googleOAuth2.generateAuthorizationUri(request, reply);
|
||||||
|
const url = new URL(authorizationUri);
|
||||||
|
url.searchParams.set('prompt', 'select_account');
|
||||||
|
return reply.redirect(url.href);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
fastify.get('/auth/google', async () => {
|
fastify.get('/auth/google', async () => {
|
||||||
throw new AppError('Google OAuth가 설정되지 않았습니다.', {
|
throw new AppError('Google OAuth가 설정되지 않았습니다.', {
|
||||||
@@ -133,10 +140,7 @@ async function authPlugin(fastify, { config, googleClient } = {}) {
|
|||||||
}
|
}
|
||||||
const allowedAccount = await fastify.allowedEmailRepository.findByEmail(payload.email);
|
const allowedAccount = await fastify.allowedEmailRepository.findByEmail(payload.email);
|
||||||
if (!allowedAccount) {
|
if (!allowedAccount) {
|
||||||
throw new AppError('허용되지 않은 Google 계정입니다.', {
|
return reply.redirect('/login?error=account-not-allowed');
|
||||||
statusCode: 403,
|
|
||||||
code: 'GOOGLE_EMAIL_NOT_ALLOWED',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await fastify.userRepository.upsertGoogleUser({
|
const user = await fastify.userRepository.upsertGoogleUser({
|
||||||
|
|||||||
+59
-4
@@ -69,18 +69,30 @@ test('관리자는 이메일만으로 Google 로그인 허용 계정을 관리
|
|||||||
url: '/',
|
url: '/',
|
||||||
headers: { cookie: adminCookie },
|
headers: { cookie: adminCookie },
|
||||||
});
|
});
|
||||||
assert.match(pageResponse.body, /id="access-menu-button"/);
|
assert.match(pageResponse.body, /id="site-header"/);
|
||||||
assert.match(pageResponse.body, /id="access-drawer"/);
|
|
||||||
assert.match(pageResponse.body, /id="drawer-logout"/);
|
|
||||||
assert.match(pageResponse.body, /id="access-overlay"/);
|
assert.match(pageResponse.body, /id="access-overlay"/);
|
||||||
assert.match(pageResponse.body, /id="scroll-to-top"/);
|
assert.match(pageResponse.body, /id="scroll-to-top"/);
|
||||||
|
|
||||||
|
const appBarResponse = await app.inject({ method: 'GET', url: '/js/app-bar.js' });
|
||||||
|
assert.equal(appBarResponse.statusCode, 200);
|
||||||
|
assert.match(appBarResponse.body, /id="access-menu-button"/);
|
||||||
|
assert.match(appBarResponse.body, /id="access-drawer"/);
|
||||||
|
assert.match(appBarResponse.body, /id="drawer-logout"/);
|
||||||
|
|
||||||
const memberPageResponse = await app.inject({
|
const memberPageResponse = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/',
|
url: '/',
|
||||||
headers: { cookie: authCookie(app, 'google-user-2') },
|
headers: { cookie: authCookie(app, 'google-user-2') },
|
||||||
});
|
});
|
||||||
assert.match(memberPageResponse.body, /aria-label="메뉴 열기"/);
|
assert.match(memberPageResponse.body, /id="site-header"/);
|
||||||
|
|
||||||
|
const detailPageResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/recipe/recipe-1',
|
||||||
|
headers: { cookie: adminCookie },
|
||||||
|
});
|
||||||
|
assert.match(detailPageResponse.body, /id="site-header"/);
|
||||||
|
assert.doesNotMatch(detailPageResponse.body, /id="logout-button"/);
|
||||||
|
|
||||||
const addResponse = await app.inject({
|
const addResponse = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -152,6 +164,8 @@ test('인증 cookie가 없으면 API를 거부하고 페이지는 로그인으
|
|||||||
const loginResponse = await app.inject({ method: 'GET', url: '/login' });
|
const loginResponse = await app.inject({ method: 'GET', url: '/login' });
|
||||||
assert.equal(loginResponse.statusCode, 200);
|
assert.equal(loginResponse.statusCode, 200);
|
||||||
assert.match(loginResponse.body, /Our Recipe Atlas/);
|
assert.match(loginResponse.body, /Our Recipe Atlas/);
|
||||||
|
assert.match(loginResponse.body, /id="login-alert"/);
|
||||||
|
assert.match(loginResponse.body, /src="\/js\/login.js"/);
|
||||||
|
|
||||||
const cssResponse = await app.inject({ method: 'GET', url: '/css/app.css' });
|
const cssResponse = await app.inject({ method: 'GET', url: '/css/app.css' });
|
||||||
assert.equal(cssResponse.statusCode, 200);
|
assert.equal(cssResponse.statusCode, 200);
|
||||||
@@ -209,6 +223,47 @@ test('Google 로그인 시작 경로는 PKCE S256 authorization 요청을 만든
|
|||||||
assert.equal(location.hostname, 'accounts.google.com');
|
assert.equal(location.hostname, 'accounts.google.com');
|
||||||
assert.equal(location.searchParams.get('code_challenge_method'), 'S256');
|
assert.equal(location.searchParams.get('code_challenge_method'), 'S256');
|
||||||
assert.equal(location.searchParams.get('scope'), 'openid email profile');
|
assert.equal(location.searchParams.get('scope'), 'openid email profile');
|
||||||
|
|
||||||
|
const selectAccountResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/auth/google/select-account',
|
||||||
|
});
|
||||||
|
const selectAccountLocation = new URL(selectAccountResponse.headers.location);
|
||||||
|
assert.equal(selectAccountLocation.searchParams.get('prompt'), 'select_account');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('허용되지 않은 Google 계정은 안내 UI가 있는 로그인 페이지로 보낸다', async (t) => {
|
||||||
|
const dependencies = createDependencies(new FakeRecipeRepository());
|
||||||
|
dependencies.googleClient = {
|
||||||
|
async verifyIdToken() {
|
||||||
|
return {
|
||||||
|
getPayload: () => ({
|
||||||
|
sub: 'not-allowed-user',
|
||||||
|
email: 'not-allowed@example.com',
|
||||||
|
email_verified: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const app = await buildApp({
|
||||||
|
config: createTestConfig({
|
||||||
|
google: {
|
||||||
|
clientId: 'google-client-id',
|
||||||
|
clientSecret: 'google-client-secret',
|
||||||
|
callbackUrl: 'http://localhost:3000/auth/google/callback',
|
||||||
|
allowedEmails: new Set(['allowed@example.com']),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
dependencies,
|
||||||
|
});
|
||||||
|
t.after(() => app.close());
|
||||||
|
app.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow = async () => ({
|
||||||
|
token: { id_token: 'test-id-token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/auth/google/callback' });
|
||||||
|
assert.equal(response.statusCode, 302);
|
||||||
|
assert.equal(response.headers.location, '/login?error=account-not-allowed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) => {
|
test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user