From 66fb3c8590eba4d1aad1fbd7094d854f622c4dec Mon Sep 17 00:00:00 2001 From: Junya Ishihara Date: Tue, 15 Sep 2026 16:51:02 +0900 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=88=86=E3=81=AE=E3=82=B7=E3=83=95?= =?UTF-8?q?=E3=83=88=E3=81=A0=E3=81=91=E3=82=92Google=E3=82=AB=E3=83=AC?= =?UTF-8?q?=E3=83=B3=E3=83=80=E3=83=BC=E3=81=AB=E8=A1=A8=E7=A4=BA=E3=81=A7?= =?UTF-8?q?=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通常ユーザーの「自分のシフト一覧」タブに、自分のシフトだけのiCal購読URLを 表示する。Googleカレンダーの「URLで追加」に貼り付けると、以後シフトの増減が 自動で反映される。 あわせて、購読トークンの設計上の問題を修正する。 これまで /api/ical/user/:user_id は .env の ICAL_TOKEN 1個だけで検証しており、 そのトークンは /api/config からログイン前の全ブラウザへ配布されていた。 このため購読URLの user_id を書き換えるだけで他人のシフトを閲覧でき、 /ical/all で全員分も取得できる状態だった。 変更点: - ICAL_TOKEN をサーバー秘密鍵として扱い、/api/config からの配布を廃止 - 購読トークンを HMAC-SHA256(ICAL_TOKEN, "ical-user:" + user_id) で導出し、 user_id と対でのみ有効にする(DBマイグレーション不要) - GET /api/ical/my-url で本人の購読URLを発行 - GET /api/ical/all-url で管理者用の購読URLを発行(AUTHORIZED_EMAILS で認可) - 購読を想定し Content-Disposition を attachment から inline に変更 - クリップボードが使えない環境では手動コピーを促すフォールバックを追加 - help.html に学生向けの手順(キャプチャ付き)を第6章として追加 回帰修正: config.ICAL_TOKEN の廃止により、管理者の「iCal 購読URL(全シフト)」欄が 常に「未設定」表示になる不具合が発生したため、サーバーから取得する方式へ変更。 再発防止テストを test/admin-ical-url.test.js に追加した。 テスト: 412 passed (30 suites) Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 3 + README.md | 25 ++- docs/images/my-shifts-ical-copied.png | Bin 0 -> 23548 bytes docs/images/my-shifts-ical-url.png | Bin 0 -> 22753 bytes help.html | 94 +++++++- index.html | 11 + js/modules/api.js | 14 ++ js/modules/auth.js | 1 + js/modules/config.js | 2 - js/modules/shifts.js | 81 ++++++- server/src/__tests__/routes/config.test.ts | 57 +++++ server/src/__tests__/routes/ical.test.ts | 195 ++++++++++++++++ server/src/__tests__/utils/icalToken.test.ts | 80 +++++++ server/src/routes/config.ts | 5 +- server/src/routes/ical.ts | 84 ++++++- server/src/utils/icalToken.ts | 46 ++++ test/admin-ical-url.test.js | 93 ++++++++ test/my-shifts-ical-url.test.js | 224 +++++++++++++++++++ 18 files changed, 988 insertions(+), 27 deletions(-) create mode 100644 docs/images/my-shifts-ical-copied.png create mode 100644 docs/images/my-shifts-ical-url.png create mode 100644 server/src/__tests__/routes/config.test.ts create mode 100644 server/src/__tests__/routes/ical.test.ts create mode 100644 server/src/__tests__/utils/icalToken.test.ts create mode 100644 server/src/utils/icalToken.ts create mode 100644 test/admin-ical-url.test.js create mode 100644 test/my-shifts-ical-url.test.js diff --git a/.env.example b/.env.example index 369981a..15aa52f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ TIMEZONE=Asia/Tokyo # iCal 購読エンドポイント認証トークン(推測困難な文字列を設定すること) # 例: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +# このトークンはサーバー秘密鍵として扱う。フロントエンドには配布されない。 +# 各ユーザーの購読トークンはこの値と user_id から HMAC で導出されるため、 +# この値を再生成すると全ユーザーの購読URLが一括で失効する。 ICAL_TOKEN=your-secret-token-here # デプロイ Webhook 認証トークン(openssl rand -hex 32 などで生成) diff --git a/README.md b/README.md index a8e0fd4..f70285d 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,27 @@ ICAL_TOKEN=生成したトークン文字列 ### エンドポイント -| URL | 用途 | -|-----|------| -| `GET /api/ical/all?token=` | 全スタッフのシフト(管理者用) | -| `GET /api/ical/user/{user_id}?token=` | 特定ユーザーのシフト | +| URL | 用途 | トークン | +|-----|------|----------| +| `GET /api/ical/all?token=` | 全スタッフのシフト(管理者用) | `.env` の `ICAL_TOKEN` | +| `GET /api/ical/user/{user_id}?token=` | 特定ユーザーのシフト | ユーザーごとの導出トークン | +| `GET /api/ical/my-url?userId=` | 本人の購読URLを発行 | 不要 | +| `GET /api/ical/all-url?email=` | 管理者用の購読URLを発行 | `AUTHORIZED_EMAILS` に含まれること | + +### トークン設計 + +`ICAL_TOKEN` はサーバー秘密鍵として扱い、フロントエンドには配布しません。各ユーザーの購読トークンは `HMAC-SHA256(ICAL_TOKEN, "ical-user:" + user_id)` で導出され、**その user_id と対でのみ有効**です。 + +- 購読URLの `user_id` を他人のものに書き換えても `401` になります +- 管理者トークンで `/api/ical/user/{user_id}` を叩いても `401` になります(用途を分離) +- `ICAL_TOKEN` を再生成すると、全ユーザーの購読URLが一括で失効します ### 購読URLの確認 -管理者モードの「シフト一覧」タブ上部に購読URL(全シフト)が表示されます。「コピー」ボタンでクリップボードにコピーできます。 +- **管理者**: 「シフト一覧」タブ上部に購読URL(全シフト)が表示されます +- **一般ユーザー**: 「自分のシフト一覧」タブ上部に、自分のシフトだけの購読URLが表示されます + +いずれも「コピー」ボタンでクリップボードにコピーできます。学生向けの詳しい手順は `help.html` の「Googleカレンダーに表示する」を参照してください。 ### カレンダーアプリへの登録 @@ -135,7 +148,7 @@ ICAL_TOKEN=生成したトークン文字列 - **更新頻度**: Pull 型のため、カレンダーアプリのポーリング間隔に依存します。Google Calendar・Outlook は約24時間、Apple カレンダーは約15分が目安です(リアルタイム更新は現行の Google Calendar 直接同期が適切)。 - **連続シフトの統合**: 同一ユーザーの連続する30分シフトは1つのイベントにまとめて表示されます(例: 13:00-13:30 + 13:30-14:00 → 13:00-14:00)。 - **特別シフトも含む**: 通常シフト(`shifts`)と特別シフト(`special_shifts`)の両方が含まれます。 -- **セキュリティ**: URLに含まれる `ICAL_TOKEN` が認証情報です。漏洩した場合は `.env` のトークンを再生成してサーバーを再起動してください。 +- **セキュリティ**: URLに含まれるトークンが認証情報です。購読URLは他人に共有しないでください。漏洩した場合は `.env` の `ICAL_TOKEN` を再生成してサーバーを再起動すると、全ユーザーの購読URLが失効し、新しいURLが発行されます。 ## 使用技術 diff --git a/docs/images/my-shifts-ical-copied.png b/docs/images/my-shifts-ical-copied.png new file mode 100644 index 0000000000000000000000000000000000000000..e1297748ae33195bcb743693a18b2e7a48c10f7a GIT binary patch literal 23548 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 literal 0 HcmV?d00001 diff --git a/docs/images/my-shifts-ical-url.png b/docs/images/my-shifts-ical-url.png new file mode 100644 index 0000000000000000000000000000000000000000..1a8eae7835b70ebe1e533d04a45053f25b3cc511 GIT binary patch literal 22753 zcmdqIWmFyAwk?WlaDux8hXBFdo#5{7?(Q1ggS%@8?(S~E-QAsA+27vhoFDhKc3Zpe z$7}D;YDHC1Ypz*i_R&Z0V}{GgiXp(^z=42(AV`P{D}aDNas$U@FkrwZ(mf^z2ngtB zb0Hx)T@VnrjHK9FNpJz=5I%CE01>cc4Cn<5RGxM}3>-!n70h5W*JixX)QrJB;q0!F zP-Gg-?qxwQ@NNw+u=2V0bTn2XstkRT&P;q4SD(~=wNki1vdPzltXU2o$9>+kxEMN^ zP^wuO?fjU$2k+pI6)y&y5RJ~y?nQ&n#^2kpFue?$G%qu^PPoVWA3}Z^c2fCZr0l?+ z%i5)y6n)6F@f_*~-*3w`etS+#>*|ySZ8ZFsw?LnoYGRM9yLn@U-6tj zlbDvl$IO8jk6cGx7GwJ76||*YWa)8PMI8hL8AL)@K*=rRG!xPdT@^FLbO)A<-;U<| z{2aa5Y%4UcP;PY%KcH2S@?1eHqE!mMn_LYB+4axIO8eJOzv5*gtd=jluXocj@tYm3 zCtWAnT~F7aASHv41!0K)e$?_>k^TFSI7p2g{p>$i!jcS3r{$Ni*0Kwdh7N>D&8?h# zueM)xmkm`7&fU#_(M+wHvQhoF!k9LIpYs9YIo#JGUc4I_aF0koL-NY z^X01K|4iZ#-;Skt#O3AX+sngZmG(+^H;FM8l57V1beVE_zF3UOXfmBFh0SUc3T(Mj zY5cV9N~3M3=YvgcCWqsGrDmgthsW~D%FYgOw`3w^9H+B|6*M&TV!7(%!h(vV-yGFJ zDtTt5ZY=8pDGN@iCv(cC+}Whz+m-#{WO!a)J^lcscs4l9n#p`7D;^v?d<+lyRlbkN zpvzsxAT2_RJ%i-4SAE6aYEHbN(+{KkY2&yhb`Pqac@agj#5py105)-=M^R4(VMM+XO{rl#EbQ%L=hm<(D?H5Po)NN{it4i52S z0cKA(2b1=xP{+sM8kP6s>1@|7YHDiFw@21iPKL-P<3AzKKv|;S?08l?yggs;&+1M< zYx{)5QMYIc%52tJ6nb)sok@UEv^;jIPv2^`mW^%Yr zNX;Rrn%$1g;VLBVR={zDc$V3xjD}B#%`@~z-toP9C_$A}>BXKqg6G>QGZ!QY$#41T znWGbwOYZ_H^GF0P&GD?+tyz!eZ5K`M_z|)DbnM4Nk@`er{y^*Hda9pEB~RGQCVdmJ zv9Sa^9&;7yXb#@nec>ZJDRi1HZ_mz!s^{--FH`rESv(03n}$_d%`MI+Ur_#uHQIg4 zuzQW)7HduRy}iALn_d3E%!;0BK$Jw)xtuLK9!zj>apAr#3=S3pZ_oGjD%7Gg%mKd8 z8Y=8YFc_}XC5zA3XDpR*gva%K)jx6zc$H{hq+^|7`d)uvGv}!FWjUWLC|7E*n9mf* z%gejp?6d3{*T`O*{-RNw;w4g}&_#Jc_%w|%d^er2oh*KH>Rc~b@0&8vAfa4*;+ntv z_3r2-j?S|G+sFFRX_PpF?Qaa{`o$klEcO%94etUP;@%WuVAoh zndA0BiQ;SGzJ%$&S$=}*yUoC$><~F!V~N|}OAkUZMS_7jT&&KHjEsDQQC0N^gCtsP zZ)>ZGBNaoeC!L0y$l^(-Rj*rZcFgzZ^Lnh2OrpLP;dD7IX`x+q*3-)o{s)=D;dLb} zOon3h&sUr2H0t?<5k1tNKKCrTU+>28*(}!&&xx$Jd#3Psx(u06b-?I?knF1eGWqNo zTd@4wveA52)XVGkQ2w{Yyl@{uR`B!PNn{Q8b`_RzRDBthZK3A;rnMh2%cH2B2FNVL5C=zL< z#5f7{dA=9L@-c`?U+ny3N*hlt6p*_}@NB}EFn+K!OcBu(QhI2A`PPyy#-HgBjX=Xy z)&@d;^GEmbB_FL@X+&Tdo%IH~dClXN1G;N4BC}GdAy&3;Bs$3Rdp-}L*zbxxbd;xe zkVjh72(!k<#!y1ID3MR%{$}4(=+{P57znxDa(?e}C-4Ta)*-uB8+_if?AX|yiwp0-8HO9roA>gtGRoY^uFhh`Fe*lpxrU)Gr z6qMQ-fJ`hAPv$>{;faTjudS`U7lXWet&dC{OF2bN;)m4lbGH0jrBVa7zr%hrs3J-O4zRO+$=rdS3qDkk>w^fX_o$p*j)>OV6~Mh{GJ zhxJNBsbaC<6e7;I67$)jviao(+l}*`oqUDsBDH1*a&(_ErBZ2pxjf`XYm4#qIs9M46WD^Lzv>n3+)X z%i(x(DgHd|nQHeAXGw*8U8?<;6VZOpTk%u9>ZcO{rSfH2K9vvQ7<00y!1f?4c9Jc4 zsMaXmD$N~)>ketqmm6OZ0&dLQ&NwKr_wJjRxEuVzp6B0^L542b9t;z>s`g_E{Z#@Y z6k)H0T!MLg_?R0F9cb}R*BU`apr0d_dR;GXlZJEe^0~-Gv3vnI&qvkB*Oi`#-`kdh z@6-RTGthP!UL_}tX+D@V`q`o8cbpfv;Q&L-TEE_Jt#%LRhx4_(vR=%Pu;(luPeeR+ zq7FK(rc22~_?xLs~~+#TyrmdNLmy}hCzx^LYqH`)^7>6pcAqiaFPe9<2#`DfDm za2~f=zkxZ0MNR%>DVIv2mrADfr(&2y^}X3gFgT6EWt&K0(5+bH0N4VM>;S+u-%kY) zs7mAaapQ&$jZWKRjef6mC2WjcHg_oj4me5=fJtF7)jI781nSKW##)l%nBph}XxqXp zjs&?P+=f7QEQAnIs<*Lgovt2mk)^f89Md;7Eyr%Aq;%w5QFuD*C8_VfS?aurK_S6A zt~axJsTAvx)e1knQOvsz#wS`vVI5QzV`yJoVv*wF?|XCK%HQSnWJZm9T~$9dn&!0< zklWI7!N+Zxqdb!l z636L9zI97(iExlF{PRC}qqv{Mb?WAvk9nz|*iwDIqd_18ZwJal2Qa=F8XpD?P`Ow_o+dKe9bwhGELbBsHUR% z44;k9)FD@arq%gdelW1JAwOF~Y@$6#Orv5hC{WzlY1=li!>4=EQ1IUh*9{+n`Nwrz zoJyCXfBevDuu{L?9iCQdbvdIhigokwIA5v-&(kKT2O~Odb-nP}>Vagm?ps`ZSg6#D z*VzL2=)pv$W`@*1d9gO5noZ+(JwYu3e1}LRMzL&Wz?!W|Q!5cbtO3x<&CLzV8*Jfi ztE{wc2Nu&pWh($pxk8~NdTuZG*7F8;f4PnB6V!%(Ow43&DAHbaq8P9aYk=IbU9^nk zuY`Aca8TS*nmFZmsw6s>wB*Hkq}kk~br;W>J^H)#0XKr-9o#8 z_2p`g8g!$Wn3UyxNaY&lLax8#SzYwn&*oKM3_6T*B$$N>4Z_h8m?Aru88V62G>S1_ z8jmCS^HRB7Fp>kgH8b?b$Ig$pY9MyX7@62?*QGPqYfn+Q+%Ab^QC4i%+teFv@be!2 z8>9oNicBZ7f-^)f@dT`H5+=#tBCawyojDzLhX78XG&+$E`m;?U;fvfU%rn(zu3Nr$ zQWzw2;(G{4NbMFUbAU9A%=YW+y*^&SW74CqG&}4Gj@0`5*HP0uErk-e{G}&r!zE;7LomYCW*bbbt9EpM zzeGr?vGl;mG(WB53;V*|%1lvB+G?+Iqu%v^Sohe;9xa8K z=t^p8&_M^|$myS7gFmoI+0g1cfEAX{p#+dtfT}-SY&_iGhoH$gFC^1xPOTcm!h5i( z0E=;NU+CWii%|D_y%4Mr0f39X0pYxDE-uT<=)(`|l!erGF<2b0;l5u{B% z01PC0l8}((ydnXZ&49BApeGap&QR}8HlMG~ZwqDNyMB*rSy@@}uFI|7@7cUjAKR&& zAMcthcRa+XpXm_NOub}ZOptcq=k6?%hStVS-iu=V51pu-m!(<0;Ay<2giL7rI+-gj zV#giy9*~Nr(`Eff3()b!63rrSKkKsz$3J6{8;u(n*zb*zs|+%{!I5R39%M5adhK$3 zuk9O8_q~{_Z%K#}efD-eOvAO;$--aU@F0_Z#>;5K+1j%v`^`7+QdcXXhvFtCHV7oy z$y7%06*F3c8dxnW%~W8#9Gc!E3f~X8S-5TCG;QJy)t|Jd&smEb|K62vozx-?el^$<)ea zT(0M-97NvFw8oZOlP?@et*M`k+(s-zo+9QxLkp^;MY z*_*WU`X*SBXSiZQDI9;5>dfRC@xD|XS#Qy%UTR+Mtz?&2YBf7BhJGrwQfyu!(%3b#^MMZ}=R+-e*==%#g30!BVf$fx8sqEqyCXt46Fe<A^4`sw!=34})C1ej`pcpQ*h z7An-47u-+dd4uSwLVjz?{vc4?0N9mK5D?E4U%!4;uQM{BNR*h!;MnqO^8M&Qrb{lE zDy#WDJ#Mbc!5Ll(o`{r45xJJr5Ka{T;epKJoMa==bbpr#e^BRnQazr~zrZ&XmJh-7 z`(Td$DgRggZ6}Uw?1}1y@PrN_DpnqEA-~Iuf%|E0yX|Turvp_Fs&P7Bx^_EU^cKqU zcxW#x0o<=$a_js1=MR@`-L_VC^M!MGOC^zqh{sx~K%4cLk+|uF{Nj_Dvs~9K(LaC8 zV&&6jmzOE<5F(45PM2zBGdaU!7<4;U>&<5~I2`MYM?7 zcO=OYxEGitpd!pDFF)BIPe-xZs56Fdvwc*lk3;6Ugsf2iCHn8Qt2PG2AHdwL=1SuJ z>M6y$li43Y@BwAd@7Kgn*i4kgn;M97?7NMnw#3XoznRbcwgiYcu?DhiU-uH#5k)V6 zl6Z9m2K^CfOvdr3=o8U6!oNIa5xzd3wF%gSXYx_65K?mrCQhtiIqddot|If(XD#w7 z;*4wZoz-R5GOR!$Z20`}<*ymGM+Q*4Y8ee~nqGY@4L2^VN{B$yXJiL+I_&ZVT0Q52 zPBcGmAzEsCwZjq5FY``Wi($&nreC@68f-X2$s+}wB?Yj(onSS10E9Phq`S6}SSsr~_u zSNKP$@1T9+r~|7id}U48uj%>{tDt3VQ6{&%mEm# zZ(rnaQ3*)@t!BXx?`m#tnH>P4QOESz)@y=K{r+sE=n2Z$;oX%zcds;hK7}kTI9)mH4%xrIF7;qR}sQ*WF z`iX}sh3YlBqm{^Hj;a3})Q|3Du7O8m$VP2-iQg z9}rxHa`pexxBm|Ce}4fOdzkb8S={{J>E8wv|24$y>s{m9iG2OVb5V=m@k#m9;=zu~ zY$e-?RF##t)q#1Lu@K+Y%xWfSszE+n|2=CStMN_6(h1YU9C7FjIZK1Ii1j|r^!%LF z>)}DZ`g7G)AG~X_)3V|{P!Vt4nI{0&kEgJuK=F$HYt>(8iQy1)k={PM!6yz`zoux!M2) zPW?Mf!jc513BKFtbmHLPko;#-o}imr?1cR|Pz%f++TIqY`H%n(#B}XQ&xeeS&1k<` zb1>Q7Cq_tk@w+@(m)+On{j@Y$w{LQijh1%oddJ&)Ow^ju?8;(}g_sI>HtCrzuh zdRBIJ@#j?{)8%?IySr=1d7s4T8iDq)?AHBNoKEZ0C2lk&~_t z?d57Fiv8z#sUiO6fPV&ey`kU75PpE@ls-METn9$L6LZM3m-K2sGQtKu=>rA2cSl`! z(wCy(6~2<;GSnl6bg}XLGBAoc$z(L7R3UscXFemD9mti{sMGGHQ#$gHjOX<*VLhqo zt6Zuwd)WB??xW4s@RR4G5uHjgtwm7WuBJV(y}doq2$UlF?dh=l?Hgg%+#1i*p45TG z!u!@tzvFtfjz%hYO|HJ>}UzLQgZq*PuZ4cpI__o__IIP-8+a@JRi>mU?X)&_a`xGjDC zZUF&1Y^U>uV&T#{1uOmq4{5AFkAuk~5lQHt1iVeAhK4{cy+2>q$|K1D&%@HmM<}%TJjGW3fV8HzG=H7V4nC6iv*) zpk7};7Zmi}kYYmo-5d`7Gu3**pYKDWN*WVf&m%d9-d29*=h@HQV%f0HhuWU>UJhWb2svFsrEbvi6arKhclizS1jO`+0q24@-82O(@C+jXu z$lhGbB%CLhQr4TZyw4gxXr`8%}XcPiSay_6^qyg4IqghlHN$l8Z@pNo@k zJ|UUtF>MCpD)|GUc-R92Rrs1jr@c-=A)%qcp`klFr`QR2czCs-FIKO+>et~S5y?TI zTf4hI>MLKZ_4E3|Z+2;j)VrR37291s1AepTmnhum57vg3Vs`IitaqoQnCRMHjk--v zQP-=Gjz!89T5qk=X|Aj(zuuM%;1bPSP)xVazJ|ObBo~krkAoLb=-21RNnCT8fBovPe^(XW71QV4tA^~$!zZMnt{ zQpmLfs=%ohXUnIdwN}^f>8vtJFF=X3EpD6-LLr-RxZeJ$#lXhwd`oN_~ZTMA{_NdrwZOXn&t-q5013}SsfrHBN1@BJ)CO; zV!1pCmBL|LHcj%woW}ciiM}=4ZGm~!QKBnZ&B5)mF5itL(d=mn zjsiB!2~bnYRsUeM>+bM=k&~F>w<`tCA+go`>ZEkb*2np&J7PX+D^&};X z;fX41!I(U;k|^6IT27(=>3vd-8-^yRE8+3P>9`z34u6nHtNs4Vt!Vt_Vf+Mlp)yDl z2SqB3aCf^z|7}WD(j(Bk?{Jv@Ioll@H;SX$?gdeMAR6ae&UZ{06EiyZ&X|~oen-cWytMow-sO>r<1ef~|*lAO+m0#I`qbf=V>_}62i&;}7(i^aE& zkD&xk_LKXjr(v_=yq@66C4NfdqM;F>RB_7(CB|htn!4v0QOKqxu-A&9Brz99D_1E9 z9chGUWq);Vut)_LTuq?(p>)pSb9>p|n&l0RnDX$t{QGgPy1mE@i859G!xepO$Lyrm)S9OAYN#BJL26 zvON~Y>OueE4GDtaXEofSh{?HkKzY$=bwQYi!cp#ksYeiA1Y$mc;`{sS(+%Jdr!0bB z{5{ssNpES*Cpgk1azzA8zSzT(F|ine{s};=<9E&x3*hf>j3r?PfRWFaH;6Ncy; z;~f57BcZOGT0NmcKm=Foha|S0$mU~E)!6-P0b@}&t&|(T`7G{NfcxzLi3QOd;scEL z9uUG%VT}ECzgr2(b$k20A$OTI0Dv;!^Qb;0K0UMJq$sY!&XDu48Q;a)CiT}l@ro(h z3ntgVJBIxPHKc0A&ny(8T&{vp1+7KM5h592b1+EgD@^ezhFCUEB(vb5qTN0J*YT7L zvGSo~i_=j+fZqhV%^UC^TLE_`i)pphbr8xf;hA>ZT_}Eo0tYeY=;PRg@)eh!$3o~Ao+24wQBiOp{n0U^xJ?DF z+PNHm8%ylAdYYKHY56{0nU6n9BGCH*>Yf1?AelGetU@Ednmy*>hSrF!y__{}$eELS z+vniP_a;SX&tL`}-o!a?lzk`KC9HGtUGo2h5;vVKem-~J(8=l&9sSfKW_iimAlZ-C z!s^W7NNRAoD#vhPcBBbdUutq@(ra*43|@J{-{wZSn7(5S)&*zeE@Af@=4;hzf5v3+TPFSO)_dH8ec<5t10ffy zke48|N`+RXb8B>%glJGGLk8QZ3w!@EYch8W+pF=UujP*~M2{Ehu#?{*KM& zvYP`C`=Om(;&mV=>a^+z5x+*`bGRM%O`{lc6SV}<_oKCY?*xJuG+iF=!LJGqZR&W3 zrgDCQbKJFa$sU8nDeh=>Ss?CzdM+4kM7@iD2mpPHJ|C#5KoO>kL0 z?KhZgZM2=p1(KHlbJ-F4>*r7$O=)4wUsa54Re7-pu3Q7%y$ zFWG@*8;_lCiZak&PR!u(ZX1r_3qM<2XHd=;ao8JnJ>HcE8Ue&ujk$>nzC==*@+fHW zM}`oCdCIN_!eCiV0YWrjib*g2xnsFYwV8mD*yKsHM&1s=;-pN8X2Khs%^3M9&he84#M&a@iuV!J!?0J1 z{=k|p2)D7rh6usa5VN^GTX_I@0SpDu>?TuyOd&|TLn3++aJzDZLP!js3%&(bV^rY` zB(t14BE_o}h|0*93BdB9$g?4hfFDFFU?}561y3_7F9sBQ!HS=qrDY!Z752%T{UYw`23I*bK$Me;?oru%XdQ46X=&c18E;%Q$&WF-=5p~rerj;(0 z%LYPZ722&Xb~>+52iY(+`QbqS4h&(GNxp?!!)hFkh!*HzjwyfDcDMZPHjN@r7CJ6T z4{<*vGoNy@UaEHd`o`|@afAx(2;>Ax*bR_lq$0osWqU#cpsMg{^p?8h$2UI*)L`;} zT%-9&pi^4Nma9|}&0Y0^_D5ISxqkCK^*2jtwJ!kW_|xX@=mr5a1NxtX^;2MvLd-)E z!$yit=3f;rr?Eobw%Efn8(OSz?%|DKIEbHCG#`9!|Bw!1p>zof#I(7VIkgH5S@Iny z-<7b!$G0Njjd6SBzJ48_a($mxXI_dqEMR^l9z{3+tFKDgy?n~KdwE+~Sd1!vqmS}V zF}W~Z((XiaId!mWC5)H6>Q|V4zwGvYoz%^#aiCCAS;^*-dM;f45<{5A=G#r&JDAPq z>alk%B9YLNLaFR>6`+2x-hQ;4pWXOSj$o;}sah#id@gqyM&WwA3}5Fpej%mI*5da5 zsa~#kU>qf3wbr?}Nj?yRRy`2p3k3IjALB}+O^HUkiu@bd@#ZE`mE4$*gTYE3+YcVEIWqh6hVc^@tCgkVkD7$Rj|@Ij>g|%WUq*jR!Qq)b zg(9zuz)&n!W?w4KFD*{k+MD0LekBUlnf4aXK~em4?&tZyU9RNK;79Cn;kI5TS6k-Q z1(cz6dqcsR54h|-5?78)CAMs*5=md89>cmEnEDNQz3h;p!-1;q)p@%`Qk=$p=f>l- zL||x3ROY?Wdep=9VfT;Yeh3un#|{bsL5S8kP<&bN!A%?~MxopQF z=urF;El9OVGU3t6`h^o}C4ca{P=HWCAaN%W{t~zk_F z@Uvfz+KCS)v%Ib!;~vUW0ELG#j#m9wsCU@MC=qU4nj6M$_^rCYsTt5X0h3g3HvPAC z=xaZ6=k@))s2t=z!zq##sgqxhOei9L4kPk!Ke**qS6gv$aiC1nKkRzV{AP}7fL_-h zV%cMXX%YdlfSM;-)|?2#V6|KiyI8IMYkw+NsDE`6&^&sd{UHQPKut(!eNF+J@>2kA z6YUK6I0&xl9lGWn(GMicQlL{ap0_1Hjw5SyZ4DnY^f>vpmka2MiSFa)5)j+U)uW~& zl=~qZtevGE)5QLBcfk~UiE*d}GgVYPzshHS-~*_+N6qx3ey zFEcYTBHR5Fjn5^w-Nb&(T?b}>6fiqGdvkj<8=EPf+Z0{89azDnTS1*j3Qq)1G$!Zb zd^~q`cPEmFBp^S>#+^C6PZTH$Cm-bHm&`n7hxJTZaeubbyDGLUhhPV<*vZR|B_a3~ z85=AqK<(T1OsPU)0I%?hm^9G8dwq@U3b7=QmPprVF<-Vb7$eplo=flM@BLrWF-hnTBG5=rSPL5@^bA=lMO|NPD$+n?QZv~cSj6KKvA3Bx5a)I!+2D= zXX537yMKF&>E+=(D3wZAevyNuhR;BpP)nwHA1@KfV69GWu5#7HvivkfNH9l7w$ z)0KeJL2yEg&{xhrnVXfs!+f~s&mOf>@_|FKbdt%gTFfbj116Qq^A>b9A3{ z=F_x@$6?ob31tEeTo*o(FqaQ*24A1%L^jWF*N1yXw4!oEQMae613J&Qi#^Bc;>Buh z(Os9(B9`Dg(p}p(XK;;g+Rs1Ss-)9?ZBw||eb{KXJOoPj?+DeB{@`o!b&WB7lCd}43k`E^I&`_V zxwSKPc)v-srVtTAv*LWgGG*u1HrOX`fSab(WM*H0<_z*@{g>s7$mqfry#X%yHltw& zJF3o)yne~>GoU`F(Z;f|kpKe+>tpS?%WS=St93t#I`QpvIBG*aYf&$n{en&-Pp$hd z7!P~N+-%fcvFd(Z_&t025L8OlhTG$@D_O>JvGTn#+~!qWTfIdymG#je1nDS~%k>>l z1?AKx+p_R@o5Z}zHIn3vNb7Ea;+Ya+$sLX2c;B__r~k3vo_v`S6^znp5Pl2}q~{?} zkP3x-^4|-xE&+p3*4q7Yp^>|D^sdrp_6BI zWyMv1{1d^|pAN>j$!HjLh&feG&J3T+CCK~JsKk?BQ@~Us7$t^U-d8;RS=9sIm0fs#xwZGEU&XpnNNL9;284i=kv z=|lu7Co>gQlE!Q@=0_%qB1FbdK*Tn65@E3cD2%>98*${I|AMlLp<=&jhuXNG)gWE0 zNbFvTr`tD(DioYxUbbbtU9>B7N=b3CF#g>({gC`a0GGoYLYsP6;c(O;j=*BVM^-~y zguC z!+S5~g=@B`1t~~LmH=`t`aU`=#1_z6uJaNw0kn-%TI|w&qxRsoBF%u8iClq2|GEv574)1(fMixhdEG2z2cDxbeh(a$B8J z%uV5s38lx!2YW^yUi$vIZZ6(4ZKR6gdseDO5C4oNj9oz2o%wJRk`s#=3^gG(eKc)9 z6b?HM`m=Q+Z41L~OAkZ->A;pk5R+JSz`Ix?W=27tMJ`6JPtRHKK@vl>2TV0YR$@fv zt7cj!$8OI?T-*SeQj`KBE9;j+F{S$Z$cX!Ts?!iBaYpCD36h9uNy~@FR(8vCUf9N8 zNmUyNo|~VKk{`Wew$4p(@$(T7V(eY&VPPZf?L9g>)pUxLhOLn@;cb-e62b>!bcGja z)3I{huIxv*_&fcYqG)TB>P;t0(S~7ABN)42oG@uCZZsJ*x!Jyb+g;FQLy1icjo%+v zz3hJDa^?OQXy8WU^7R)imc?_wyKa8BXzmAGitBRWmy^YHWZS@JXEefTspk&Qa&Eo; zYe>Kg@uNp2+~~Noe|LJ_dWo}*xVmcUd1MrFI2cda>Gf77O73u)&IkyOa5;Uo-z$bo z;$3dN#37UTY1J~Q=0=$~+8=|pL#)p4q=fD@Fq$%ExwPv$RO=HcibVi^0rF1vX0QW& z|2|#0=obJPDR?noxq-#_!Dg%Tfd7^IDb()mNO4fFU7&rmSm;{d+-AK=qMZzs|B)J}Jy#;V*Vb2s6psq(sw?%`>Prm^-c-U01R zkaz}%_f@LB9dIMrMS{0}*6n!!zlF4MzPi;LfxTVEqA=)iXIqer(I15x&Xuj?m}C+H zqDa&JgRvd=uqC!yYJ)j}VMleY<4-4x55B}4JJ_{0A7{W29l8hnOjhf4NhK34h%|GH zqZi*EpuQIV_E`|D2!s|q6|u$vxdAl}4$H!`M~L=>Bxon5WjiB$nU9}T0~B8{ZSK^fYCt&64V|7O#`B9KNv&!HIgvD8gdw_;5n;o>-RwR1NviH6jM=;ox2 zne6yK-rwxux!DWAe2|F+$lr)yGRS8_R{2pPcj8n-dW)|fhX{9@9jHUGa2dYb=3(az z3D;wIlEL%C_p4v0>WkwaeixVF-T_P*dF;MduvHv-C<)z>At5DmCw1iPGIH)XR{4<< zkeSfEZSuEAME9_uEg6LJ#%!P8s!litdAt^+We84RWChRys}btpqWKchw=Xd;FbEKI zw*uljIzY{ow&(;o5eC;Iq2mN7{9K4}xNzVN-UWjVkgoASbrP%9f{?U1Et;^1{E~yK zgq)~`gti<6k4Agjrq@xjG3hYp!L#Jty!yHz$dD?L`u!(_7tTaIx#K=5(a94}zg`LV z+ypF>_pCX96371CePi_HC*LxQ5vv328dD4U)hsxh3_ue0imV?MjfdN%jWMZfPK`oI zk-SkxFv-b7it@jt06QFj?Q28)Zc^h&)N}pXpT4LOmy3*xeUGGv>~?fgal^1Oj8Uu5 z>7b1O_kn(-kAuxC7I}9)S&$j>bi>)RvFQx266~19xo~LZmV^+PL=2M2bjKu#0WYl3TK>hKF`1_@U~htrXTm z?H!AYmq~@E%owdl;daDW<53Wz~klOc4f}Krpr* zfEEoUHo>oi5jHb(50Ng%nB|jXzO8`W3-oGy|M;q@ITLJ`T_~BfpCHu+?BSiZwaCrE z7*B`oUvK=d>-E7F@Pt_UXmPJJIV03)JTWWV5e+yPOZ|k6WKYwN&)rmg!DS{+c^n(7 zmtXy}7wJ>t7SMdr*SbekiPqZEa4}VAn&J}sd)Aa8F1V_*bK^KkYPtSJIcd4!%=c`q zU8okdFsTmj9Lk6tk1{H~A(eTSRBGY0?1f|%K|PCGB;qPRwC&z!Z}V`e{Q$&lv|xBO zZsM{=t)kZIkK^k5OOa%KFJ{6RPqV`$ZuTN_Nk~ka{@MkdW;@b#P)JY-)cjvL>16@I z{RT6G%~BFji}w5p0PqX>D6yP;4>|H*gTfqZVd>nP)O8yQgDp}K4lIV9e0fd7%zLh- zZw~bmWlg41rJ|;0sxtR{+I9yTKPIwxqs0=S?Yra^TBL)EJ}iH`y^m4E5JW5#Ob$$T z>d{&Sx4Cb$YU>SC^(Gbh^(P$m=0Sb+&CO9l<3v{W(-1FEdjv5_;2u-9?ONBQH4n ztX(^Uq_|N!k|NW2BKo4eI>Jf_0^$YGE)7`ebcp-{T@aWn*SGS$2f@oIMZZAeEI9V; zO8M2pV`|b-qysAg*n>(VBJ($M@a(8yU{CwRYI~ZA<RuWa$5buKxwFhkiO$27VO_8bKw8&W~ z-{`gQdOnBuLG`7I6ZGu6dCemmkh6mvj1cDLY|8jS5%7AkYtb19I7<-bCQ&HX+pOW( zODTzyp+-~h%@ef+lYZJp*peYWBINa2s?irRgBWLrBSNsKgt=@5@^fR?VM~0TUpI@P zXxLj0V5RJ&75em~vW|F;^vHGjqlX3D6}5n!K&2y^n^%|VP0{#W$D~f`);L7J_di+y z)Ha6MyWIw`!%TUCWbOmlJKJqxy-nV4u-7re|xP`xqN$mgIk6ubcsPCTKfN`a9y z@(5#WtLei@?%gVSusZ10`cD2aap<6J$Bc-3_?kWI?gbp#haq(*pW6M7Rq|;Xb-U)+ z^Fx4?B8rgLP@Mnu-T+WtgkHCuyM-n$0O$S@%M}5uaPPdR_7(H`%HJ@+*-pv5Ar^m1f8Fvhrw-h22ue8drmj z&Hdh}knnfco9E9ZeLI7QVnLyeX7f6C zJ!j0tF?`;yhsR0qK;^nWtf?0;Xf zO1IS}&^{kZD688S^ay56t-`I8@M94tCa~fJ=rvmRg7iJhX+`Pjf7AuUQ{cU$Qe?@F?Bv~0AUOR8_Z0r^MqZr^)!f&#;oECYi8VgY}Du^7Ci7IW?APuRti>hY`FLk&9LIQvB-}sA`g?z}{!-2&%!=$F@D;(Na5uwPYE62bHnUsoou4aUWLyM)%-l++rXCc3?H0z@iqS&&(eOJH(L=yOXzDfy z^tXofH#n%39=q$OQ>W+-?2Nt;_wK4%gcIc!#JrL0!IK|hc^Mec6eKw}x78b;c6u`FA&nXBx#oQU#Lg(MEjSBr- z-7Jc;`>huqq%qiHffK9~*jq26rQv2lW~d%v_WzbM0%OR$7?3S}qoW)1~S3v6V8a6ZwEck?BsRe8YyP{&|fc z)@Eb^^7tkCT-tt%*-1r1C|vn!l!3ELqJ*J8Z)*&xtfJFOC{r&i&y1PO$yL z@x6^gc7ey%H8DdAm6?*K52DD&H&5b*I~7bb;|aJ>nBOW9&Ek*$^35QWAXWYG~*P=GiL|tp@3k`?ZP5_s-ugo-Mv=+|sjb(v%s8w9%*7dQOmgq%*qm~&z#iv@De0M{716X<) z^TS_79+MLyhi7K87BhC%Dg`^Xmc!?#eMXOHjvl7xE4#W02zW;=j|3$(U+r@*R$G;R zDwE8-PQy!83=%FE>povlP>yLO4=?i4A>K6Uoj3s?SCy_B_=H9!G0{s__H~ zuZMvlj!O-7r3s|K^E>{56Ea}U4jZJ?gmNL<)6DA+bS${FgS6hGP*f`_(>1^w^o@3WUY?S}3ihF-N$CI=qk!2FtA-UZ;iwa(_2|_PmH_}c)-Lf5-PbL^Q7(EF zb39lA(3S?xtl{<9KUff+n4AE=Bjil*q#-M(IyV}* z@DHR<$0c*QZvdijm}Nh;p*eTSqhnBZ`aBK$w5`J7y*CD{qI@ax>Qh2VwdnVUpL5*- zm-DU=pYo-+AtrJ;mTQhJjPJSvV z8Z{0WumHx3c?#njxiLBr}Z^U)72IrBjEg)&HCn?gaD!J|qfZ zSP;d|j*jK{o*5-AXSKi);~o<(Em>)@&I?fhMMt9J-gKi!5Lun@>@{fVnZzo6o{ehS z-WSQqa4Fo6B_Aad1%Cz0yX+$H(ilMLpXj)}Ir)_C6qrN2put4;8ViiHpb%<;e;|& zMEdXOM2b;EuRBi`6Bb$fsJ}AgX4d1b&GqpA@CRNn@yH;@Fuwd9+*0oTYoi&%6O0S2 z1oJw*FqPjiSe(0hthgHJ6jj4esA|F9=cWSO zrjYP}^W&tCZtYnq8Vu^pDV7@NeuC56?_t_n#;GYIm?R%pA$5Z?z}TaAicNEfTIGDU zVbU?b*NM}rYJ5H{KeetM(DhQRrD)eHOul#k`s}5J(?d(|Gc8E+-WkKf(Lh^S)(ElS zD<96eIyQ%Qi^LpS(%fAuR&_R>DQ1jCw|^b~>ZjXoeYX9M^IH1H!%_Biw3WfY?&e0J z*XG-t4`5HhA>VaGD{>5`DctGoK6_(j*Xw|ean z?yZTItB39{Xn!0+zkF}{&-hsAWWNm?lYZRk2 za&c(Rf$vdD+^-|$oke4HZlShVnG478!}>a+!sPt% zETOY|YN+Y=+dRSSC({H$GG^$BW25imEE**RL&+l5%as1S*W+P!&wWm=*VenFs{5Jb z9PwyLOMkgV!*FAD$nv0EJ8*4Fut&&f=H#t*KpH6ILga-?2#7HL@Pc4UB>>oT68TPF zVOp97@+Ft_M{1C)S5!cn4J-tOhmzt+cSIrbBfu5j$zt22Jt5PpndlqY!H0e<<%w5* zz^mjY??u7ISpEPY4-(P8VJqsLQc9AMQ7BhKcZO`9gYq=ooSxV26U)Q1wa&LBX~$si zIwOd#rJREbN8dgMG`M>(bz&boJT4fV@1-NHDm!|pEJjT8-cAaH^{~j=y^rJ(#bI?m zgRBR28a@K2PUyu9*a}@)aE6}MFHafGT93$QUiM9*l9&ORRRk88%!`pSD)#>G+?aIQ z(UUT~$$+Yo*H?T_5JVNOmrYyos3LYvJzLi1&hQ;>IohwFEcSAL0KX1Q*Dr=}CV3)8 zZF)^c_ry3$*{pax1vJ)Of6z3D*n`7m&D|=PT{++1*n#=V$Wgln8Q^LP%6{ojL5`cD z?}K{ig>GBvn0t{nx;v%-ggwk zc92}%20$T(G#IAW7)K}8w{dFd9^TXH*b{)8d@9dAWSt`U(#kAIES2N7orOT|eh{oE zg$cn>w!MSGsx+g6t*9#6hn{`6-kZP0zLkjGmtzr|^qkSOTLj22oJML9f&o%T65Fn( zfZ`#%-mw!L_+cB^AvwD+D=Mhy3mgU+kgNd)L~)faE`@XGa*CvcJXD@b zPLwBI286`N5$6K?waB-Bw)}j`Hx~YrxlXE4^DEOJ3kFL|OBH!=eyvlp zMXkA_c8=9(MrHXv%?kaH<@ul8qe0Lg-d5==vfW@7Z<`^nK~u5FA7gg+q^lf6PsnA~ zJ%+SPXSx=VRI_@7;)rK%X9pbNSYgzBpR^Qm zz%>%Cd3`Xq{Oz4%46~3ckI=aluQ_gF^A`n`vDMY=nMnOS$_IEpw_eN(- zqtp*(?eGyee#zgZpVKSB*REq+RH{t$#QL073T#wd#0a>&+HM`FMwxVFI3zhr2OM8c z+w|2XSw0I{>{LE79Xp@Wua0+Z?^4Py?Ax_ooz7Z$frfVt>u#qu-_l+ueV*I;Fu^#) zvU~LgspOHFLH=wEpYCJ#YZptp&IN5o$+$!vPe~vZltknTqH{mpf4is{4Iis>idpaJ z6nM6Fcz7Vz0rsg7cxk~QG(YbF_*o^cA-@oVZvx8~+fYZdgYe7sJ@#jxng3IbaZT7rL>!|EJJLWslnnkgd=>A>S&d${=Eifvyf+aAAg zfYJT{$}rAOf6APjp*<5Rm&Og5Q#cz8WR$@10u~e+Xh-3J2IJe_rxemm1snnhwhPr;A>Y2eY=%$iG2V^!&5#Wv*B})kd;o(nXXD*#CJ_(BxfFhtzyji8 zsA7|CzNGk6XMTFkF>i|-lORS1vdQ!*5oGK636Gt=I~z-?cW2m42We;!iG#XXB7TOZng&3pI+{!F^A#V-XtiCVV7q|U6sv9Qzp-VX)%zJ8wXiRhugji13wDA=X- z$_chLKROP25qzYG((PKj%O82^(;1J(;yhyJ5u%mXLQXT2iPv2@@sW{$2KDJY*|i_g zaHt!aJAhv(U@nHE?Lv`6`6J;0j?&ivAmmc#a<;1&p$G!IGS2F5TmEnnf1e?^yKsVE zwd)yemJNrtzluu2DkV$)JaFD&7TZX|4w$rxZy2`#34*op`kb|xEG4>-!lSD|jY{X{ zH{T2~cp6KgbjbrCA!Gl@rh8>SONkn^9ymKvZQYf&j=+6sPv6 zuc^g$f0S6UIB)PFn}e7y9O}l^pD*hErAR-^Pgv0iVM2WU_N;Z23;LE@WSQ;#|24&j z68W@ijV4lUT2WTrt5Y*QN!;A9yi;%Q{|W;faSy3Kl55Fbb00s%G5N(F z$}s`EiCs$}065F!hgU3rE&OYuaW|XNu9gUf;_u-61ptY8VG;2+M9Nl^P=D*62Frg{ z@!*6DbA&#F_to+jbRt3O?0&S|`7@FCroOv|n`~11?L|GnKpiZ97n! zeDB=gs514(2?|?w|E(#ovaMpt6AgXV_^lzc`TzWoW;^&0ba(we6txi~|7Bo9NCjZ< z*i_IZoqy}|X#Ix0oPfXH_HHZ%1;xpUEyE)_-~sLLKT#@*?6F+hDd~;xz%vx4a0{b4 I*rkO30oQ6VCIA2c literal 0 HcmV?d00001 diff --git a/help.html b/help.html index e5c6145..a511d2f 100644 --- a/help.html +++ b/help.html @@ -96,6 +96,15 @@ font-size: 14px; } + .help-screenshot { + display: block; + max-width: 100%; + margin: 12px 0 4px; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + .info-box { background: #e3f2fd; border-left: 4px solid #2196f3; @@ -286,9 +295,10 @@

📑 目次

  • 3. プロフィール設定
  • 4. シフト申請
  • 5. 自分のシフト確認
  • -
  • 6. シフトカレンダーの見方
  • -
  • 7. よくある質問
  • -
  • 8. トラブルシューティング
  • +
  • 6. Googleカレンダーに表示する
  • +
  • 7. シフトカレンダーの見方
  • +
  • 8. よくある質問
  • +
  • 9. トラブルシューティング
  • @@ -489,8 +499,80 @@

    シフトの削除

    +
    +

    6. Googleカレンダーに表示する

    + +

    自分のシフトを、普段使っている Googleカレンダー に表示できます。一度設定しておけば、シフトを申請したり削除したりするたびに、カレンダー側にも自動で反映されます。毎回手入力する必要はありません。

    + +
    + 💡 ポイント +

    表示されるのは自分のシフトだけです。他の人のシフトは表示されません。スマートフォンのGoogleカレンダーアプリにも、同じアカウントなら自動で表示されます。

    +
    + +

    設定の手順

    + +
    +
    + 1.「自分のシフト一覧」タブを開く +

    ログインしたあと、画面上部の「自分のシフト一覧」タブをクリックします。一覧の上に「Googleカレンダーに表示する(自分のシフトのみ)」と書かれた青い枠があります。

    + 「自分のシフト一覧」タブの上部に表示される購読URLの欄 +
    + +
    + 2.「コピー」ボタンを押す +

    青い枠の右にある「コピー」ボタンをクリックします。ボタンの文字が「コピーしました」に変われば、URLがコピーできています。

    + コピーボタンを押すと「コピーしました」に変わる +
    + +
    + 3. Googleカレンダーを開く +

    パソコンのブラウザで calendar.google.com を開きます。この設定はパソコンから行ってください。スマートフォンのアプリからは追加できませんが、パソコンで設定すればスマートフォンにも自動的に表示されます。

    +
    + +
    + 4.「他のカレンダー」の「+」をクリック +

    画面左側の下のほうにある「他のカレンダー」の横の「+」をクリックし、メニューから「URLで追加」を選びます。

    +
    + +
    + 5. コピーしたURLを貼り付ける +

    「カレンダーのURL」の入力欄に、手順2でコピーしたURLを貼り付けます(Windowsなら Ctrl+V、Macなら ⌘+V)。そのあと「カレンダーを追加」をクリックします。

    +
    + +
    + 6. 完了 +

    左側のカレンダー一覧に「シフト管理」が追加され、自分のシフトが予定として表示されます。

    +
    +
    + +

    反映されるまでの時間について

    + +
    + ⚠️ すぐには反映されません +

    シフトを申請・削除しても、Googleカレンダーに反映されるまで最大で24時間ほどかかることがあります。これはGoogleカレンダーが情報を取りに来るタイミングによるもので、設定の失敗ではありません。

    +

    急いで最新の状態を確認したいときは、このシフト管理システムの「自分のシフト一覧」タブを見てください。こちらは常に最新です。

    +
    + +

    URLの取り扱いについて

    + +
    + ⚠️ URLは他の人に教えないでください +

    このURLには、あなた専用の合言葉(トークン)が含まれています。URLを知っている人は誰でもあなたのシフトを見られてしまうため、SNSに貼ったり、友達に送ったりしないでください。

    +

    もし他の人に知られてしまった場合は、管理者に連絡してください。

    +
    + +

    うまくいかないときは

    + +
      +
    • 「カレンダーを追加」を押してもエラーになる:URLの一部しかコピーできていない可能性があります。もう一度「コピー」ボタンを押してから貼り付け直してください。
    • +
    • カレンダーは追加されたが予定が表示されない:そもそもシフトを1件も申請していない場合、表示される予定はありません。「自分のシフト一覧」にシフトが並んでいるか確認してください。
    • +
    • 申請したシフトがカレンダーに出てこない:上に書いたとおり、反映まで最大24時間かかります。しばらく待ってから確認してください。
    • +
    • URLの欄に「ログインが必要です」と出ている:一度ログアウトして、ログインし直してください。
    • +
    +
    +
    -

    6. シフトカレンダーの見方

    +

    7. シフトカレンダーの見方

    カレンダー形式でシフトの空き状況を確認できます。

    @@ -513,7 +595,7 @@

    日付の色について

    -

    7. よくある質問

    +

    8. よくある質問

    Q: シフトを間違えて申請してしまいました。修正できますか?
    @@ -537,7 +619,7 @@

    7. よくある質問

    -

    8. トラブルシューティング

    +

    9. トラブルシューティング

    ログインできない場合

      diff --git a/index.html b/index.html index 6057ddd..28ae7d8 100644 --- a/index.html +++ b/index.html @@ -358,6 +358,11 @@ .ical-copy-btn:hover { background: #3367d6; } + .ical-help-link { + font-size: 13px; + color: #1a73e8; + white-space: nowrap; + } .sync-buttons { display: flex; gap: 10px; @@ -2799,6 +2804,12 @@
    +
    + + + + 使い方 +
    diff --git a/js/modules/api.js b/js/modules/api.js index 2e54d0c..bb80629 100644 --- a/js/modules/api.js +++ b/js/modules/api.js @@ -210,6 +210,20 @@ const API = { } }, + // ===== iCal購読関連API ===== + + // 自分専用のiCal購読URLを取得 + async getMyIcalUrl(userId) { + const response = await fetch(`${config.API_BASE_URL}/ical/my-url?userId=${encodeURIComponent(userId)}`); + return await response.json(); + }, + + // 管理者用(全シフト)のiCal購読URLを取得 + async getAllIcalUrl(email) { + const response = await fetch(`${config.API_BASE_URL}/ical/all-url?email=${encodeURIComponent(email)}`); + return await response.json(); + }, + // 指定日のカレンダーイベントを削除して再同期 async cleanAndResyncCalendarDate(date) { const controller = new AbortController(); diff --git a/js/modules/auth.js b/js/modules/auth.js index 5e9bae0..cc42407 100644 --- a/js/modules/auth.js +++ b/js/modules/auth.js @@ -102,6 +102,7 @@ async function showProfile(profileData) { ]); // 初期表示 displayMyShifts(document.getElementById('myShiftsContent'), getCurrentUserShifts()); + updateMyIcalUrl(); // 特別シフト募集お知らせを表示 displaySpecialShiftAnnouncement(); diff --git a/js/modules/config.js b/js/modules/config.js index f6c10cb..ff68122 100644 --- a/js/modules/config.js +++ b/js/modules/config.js @@ -4,7 +4,6 @@ const config = { // サーバーから取得する設定(初期値) GOOGLE_CLIENT_ID: '', AUTHORIZED_EMAILS: '', - ICAL_TOKEN: '', // サーバーURL(静的設定) API_BASE_URL: (() => { @@ -23,7 +22,6 @@ async function loadConfig() { if (result.success && result.data) { config.GOOGLE_CLIENT_ID = result.data.googleClientId; config.AUTHORIZED_EMAILS = result.data.authorizedEmails; - config.ICAL_TOKEN = result.data.icalToken || ''; return true; } else { diff --git a/js/modules/shifts.js b/js/modules/shifts.js index a553178..976dd0a 100644 --- a/js/modules/shifts.js +++ b/js/modules/shifts.js @@ -112,14 +112,28 @@ function displayCapacityOnAdminCalendar(capacityData) { }); } -// iCal購読URLを入力欄にセットする -function updateIcalUrl() { +// iCal購読URL(全シフト・管理者用)を入力欄にセットする +async function updateIcalUrl() { const input = document.getElementById('icalAllUrl'); if (!input) return; - if (config.ICAL_TOKEN) { - input.value = `${config.API_BASE_URL}/ical/all?token=${config.ICAL_TOKEN}`; - } else { - input.value = '(ICAL_TOKEN が未設定です)'; + + const currentUser = getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await API.getAllIcalUrl(currentUser.email); + if (result.success && result.data && result.data.url) { + // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + console.error('iCal購読URLの取得に失敗:', error); + input.value = '(購読URLを取得できませんでした)'; } } @@ -136,6 +150,59 @@ async function copyIcalUrl() { } } +// 自分専用のiCal購読URLを入力欄にセットする +async function updateMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input) return; + + const currentUser = getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await API.getMyIcalUrl(currentUser.sub); + if (result.success && result.data && result.data.url) { + // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + console.error('iCal購読URLの取得に失敗:', error); + input.value = '(購読URLを取得できませんでした)'; + } +} + +// 自分専用のiCal購読URLをクリップボードにコピーする +async function copyMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input || !input.value || input.value.startsWith('(')) return; + + const btn = document.querySelector('#my-shifts .ical-copy-btn'); + + try { + await navigator.clipboard.writeText(input.value); + } catch (error) { + // クリップボードが使えない環境(権限拒否など)では手動コピーを促す + console.error('クリップボードへのコピーに失敗:', error); + input.select(); + if (btn) { + const original = btn.textContent; + btn.textContent = 'Ctrl+Cでコピー'; + setTimeout(() => { btn.textContent = original; }, 3000); + } + return; + } + + if (btn) { + const original = btn.textContent; + btn.textContent = 'コピーしました'; + setTimeout(() => { btn.textContent = original; }, 1500); + } +} + // 管理者用シフト一覧を読み込む async function loadShiftList() { updateIcalUrl(); @@ -160,6 +227,8 @@ async function loadMyShifts() { const container = document.getElementById('myShiftsContent'); if (!container) return; + updateMyIcalUrl(); + const currentUser = getCurrentUser(); if (!currentUser) { container.innerHTML = '

    ログインが必要です。

    '; diff --git a/server/src/__tests__/routes/config.test.ts b/server/src/__tests__/routes/config.test.ts new file mode 100644 index 0000000..a6bc6dc --- /dev/null +++ b/server/src/__tests__/routes/config.test.ts @@ -0,0 +1,57 @@ +import request from 'supertest'; +import express, { Express } from 'express'; +import configRouter from '../../routes/config'; + +jest.mock('../../database/db', () => ({ + default: { + prepare: jest.fn().mockReturnValue({ + all: jest.fn().mockReturnValue([]), + run: jest.fn(), + get: jest.fn() + }) + } +})); + +describe('Config API Routes', () => { + let app: Express; + let originalIcalToken: string | undefined; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + originalIcalToken = process.env.ICAL_TOKEN; + process.env.ICAL_TOKEN = 'super-secret-ical-token'; + process.env.GOOGLE_CLIENT_ID = 'client-id-123.apps.googleusercontent.com'; + process.env.AUTHORIZED_EMAILS = 'a@example.com,b@example.com'; + + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + app = express(); + app.use('/api/config', configRouter); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + if (originalIcalToken === undefined) { + delete process.env.ICAL_TOKEN; + } else { + process.env.ICAL_TOKEN = originalIcalToken; + } + }); + + test('Google Client ID と認可メールを返す', async () => { + const res = await request(app).get('/api/config'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.googleClientId).toBe('client-id-123.apps.googleusercontent.com'); + expect(res.body.data.authorizedEmails).toBe('a@example.com,b@example.com'); + }); + + test('iCal の共通トークンを未ログインのクライアントへ配布しない', async () => { + const res = await request(app).get('/api/config'); + + expect(JSON.stringify(res.body)).not.toContain('super-secret-ical-token'); + expect(res.body.data.icalToken).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/routes/ical.test.ts b/server/src/__tests__/routes/ical.test.ts new file mode 100644 index 0000000..9322cbe --- /dev/null +++ b/server/src/__tests__/routes/ical.test.ts @@ -0,0 +1,195 @@ +import request from 'supertest'; +import express, { Express } from 'express'; +import icalRouter from '../../routes/ical'; +import { ICalService } from '../../services/ICalService'; +import { deriveUserIcalToken } from '../../utils/icalToken'; + +jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') })); + +jest.mock('../../services/ICalService'); +jest.mock('../../database/db', () => ({ + default: { + prepare: jest.fn().mockReturnValue({ + all: jest.fn().mockReturnValue([]), + run: jest.fn(), + get: jest.fn() + }) + } +})); + +const ADMIN_TOKEN = 'admin-secret-token'; + +describe('iCal API Routes', () => { + let app: Express; + let originalToken: string | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + originalToken = process.env.ICAL_TOKEN; + process.env.ICAL_TOKEN = ADMIN_TOKEN; + + (ICalService.generateAll as jest.Mock).mockReturnValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); + (ICalService.generateForUser as jest.Mock).mockReturnValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); + + app = express(); + app.use('/api/ical', icalRouter); + }); + + afterEach(() => { + if (originalToken === undefined) { + delete process.env.ICAL_TOKEN; + } else { + process.env.ICAL_TOKEN = originalToken; + } + }); + + describe('GET /api/ical/all', () => { + test('管理者トークンで全シフトを返す', async () => { + const res = await request(app).get('/api/ical/all').query({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(ICalService.generateAll).toHaveBeenCalled(); + }); + + test('トークンが違えば 401', async () => { + const res = await request(app).get('/api/ical/all').query({ token: 'wrong' }); + + expect(res.status).toBe(401); + expect(ICalService.generateAll).not.toHaveBeenCalled(); + }); + + test('ユーザー個別トークンでは全シフトを取得できない', async () => { + const userToken = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/all').query({ token: userToken }); + + expect(res.status).toBe(401); + expect(ICalService.generateAll).not.toHaveBeenCalled(); + }); + + test('ICAL_TOKEN 未設定なら 503', async () => { + delete process.env.ICAL_TOKEN; + + const res = await request(app).get('/api/ical/all').query({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(503); + }); + }); + + describe('GET /api/ical/user/:user_id', () => { + test('自分用トークンで自分のシフトだけを返す', async () => { + const token = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/user/user-1').query({ token }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(ICalService.generateForUser).toHaveBeenCalledWith('user-1'); + }); + + test('他人の user_id に自分のトークンを付けてアクセスすると 401', async () => { + const token = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/user/user-2').query({ token }); + + expect(res.status).toBe(401); + expect(ICalService.generateForUser).not.toHaveBeenCalled(); + }); + + test('共通の管理者トークンでも他人のシフトは取得できない', async () => { + const res = await request(app).get('/api/ical/user/user-1').query({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(401); + expect(ICalService.generateForUser).not.toHaveBeenCalled(); + }); + + test('トークンなしなら 401', async () => { + const res = await request(app).get('/api/ical/user/user-1'); + + expect(res.status).toBe(401); + expect(ICalService.generateForUser).not.toHaveBeenCalled(); + }); + + test('カレンダーアプリで購読できるよう inline で配信する', async () => { + const token = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/user/user-1').query({ token }); + + expect(res.headers['content-disposition']).not.toContain('attachment'); + }); + }); + + describe('GET /api/ical/all-url', () => { + beforeEach(() => { + process.env.AUTHORIZED_EMAILS = 'admin@example.com,boss@example.com'; + }); + + test('管理者のメールアドレスなら全シフトの購読URLを返す', async () => { + const res = await request(app).get('/api/ical/all-url').query({ email: 'admin@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.url).toBe(`/api/ical/all?token=${ADMIN_TOKEN}`); + }); + + test('大文字小文字や空白が違っても管理者として扱う', async () => { + const res = await request(app).get('/api/ical/all-url').query({ email: ' Admin@Example.com ' }); + + expect(res.status).toBe(200); + }); + + test('認可されていないメールアドレスなら 403', async () => { + const res = await request(app).get('/api/ical/all-url').query({ email: 'stranger@example.com' }); + + expect(res.status).toBe(403); + expect(JSON.stringify(res.body)).not.toContain(ADMIN_TOKEN); + }); + + test('メールアドレスなしなら 403', async () => { + const res = await request(app).get('/api/ical/all-url'); + + expect(res.status).toBe(403); + expect(JSON.stringify(res.body)).not.toContain(ADMIN_TOKEN); + }); + + test('ICAL_TOKEN 未設定なら 503', async () => { + delete process.env.ICAL_TOKEN; + + const res = await request(app).get('/api/ical/all-url').query({ email: 'admin@example.com' }); + + expect(res.status).toBe(503); + }); + }); + + describe('GET /api/ical/my-url', () => { + test('userId を渡すとその人専用の購読URLを返す', async () => { + const res = await request(app).get('/api/ical/my-url').query({ userId: 'user-1' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.url).toContain('/api/ical/user/user-1?token='); + expect(res.body.data.url).toContain(deriveUserIcalToken('user-1') as string); + }); + + test('返す URL に共通の ICAL_TOKEN は含まれない', async () => { + const res = await request(app).get('/api/ical/my-url').query({ userId: 'user-1' }); + + expect(res.body.data.url).not.toContain(ADMIN_TOKEN); + }); + + test('userId がなければ 400', async () => { + const res = await request(app).get('/api/ical/my-url'); + + expect(res.status).toBe(400); + }); + + test('ICAL_TOKEN 未設定なら 503', async () => { + delete process.env.ICAL_TOKEN; + + const res = await request(app).get('/api/ical/my-url').query({ userId: 'user-1' }); + + expect(res.status).toBe(503); + }); + }); +}); diff --git a/server/src/__tests__/utils/icalToken.test.ts b/server/src/__tests__/utils/icalToken.test.ts new file mode 100644 index 0000000..8dac9c1 --- /dev/null +++ b/server/src/__tests__/utils/icalToken.test.ts @@ -0,0 +1,80 @@ +import { deriveUserIcalToken, verifyUserIcalToken } from '../../utils/icalToken'; + +describe('icalToken', () => { + const SECRET = 'test-secret-value'; + let originalToken: string | undefined; + + beforeEach(() => { + originalToken = process.env.ICAL_TOKEN; + process.env.ICAL_TOKEN = SECRET; + }); + + afterEach(() => { + if (originalToken === undefined) { + delete process.env.ICAL_TOKEN; + } else { + process.env.ICAL_TOKEN = originalToken; + } + }); + + describe('deriveUserIcalToken()', () => { + test('同じ user_id からは同じトークンが導出される(購読URLが安定する)', () => { + expect(deriveUserIcalToken('user-1')).toBe(deriveUserIcalToken('user-1')); + }); + + test('異なる user_id からは異なるトークンが導出される', () => { + expect(deriveUserIcalToken('user-1')).not.toBe(deriveUserIcalToken('user-2')); + }); + + test('ICAL_TOKEN(サーバー秘密鍵)が変わるとトークンも変わる(失効できる)', () => { + const before = deriveUserIcalToken('user-1'); + process.env.ICAL_TOKEN = 'rotated-secret'; + expect(deriveUserIcalToken('user-1')).not.toBe(before); + }); + + test('導出されたトークンからサーバー秘密鍵が推測できない(秘密鍵を含まない)', () => { + expect(deriveUserIcalToken('user-1')).not.toContain(SECRET); + }); + + test('URL に安全な文字列を返す', () => { + expect(deriveUserIcalToken('user-1')).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test('ICAL_TOKEN が未設定なら null を返す', () => { + delete process.env.ICAL_TOKEN; + expect(deriveUserIcalToken('user-1')).toBeNull(); + }); + }); + + describe('verifyUserIcalToken()', () => { + test('正しい user_id とトークンの組み合わせを受け入れる', () => { + const token = deriveUserIcalToken('user-1') as string; + expect(verifyUserIcalToken('user-1', token)).toBe(true); + }); + + test('他人のトークンで別の user_id にアクセスすると拒否する', () => { + const otherToken = deriveUserIcalToken('user-2') as string; + expect(verifyUserIcalToken('user-1', otherToken)).toBe(false); + }); + + test('共通の ICAL_TOKEN をそのまま渡しても拒否する', () => { + expect(verifyUserIcalToken('user-1', SECRET)).toBe(false); + }); + + test('トークンが空や undefined なら拒否する', () => { + expect(verifyUserIcalToken('user-1', '')).toBe(false); + expect(verifyUserIcalToken('user-1', undefined)).toBe(false); + }); + + test('ICAL_TOKEN が未設定なら拒否する', () => { + const token = deriveUserIcalToken('user-1') as string; + delete process.env.ICAL_TOKEN; + expect(verifyUserIcalToken('user-1', token)).toBe(false); + }); + + test('長さの異なるトークンでも例外を投げずに拒否する', () => { + expect(() => verifyUserIcalToken('user-1', 'short')).not.toThrow(); + expect(verifyUserIcalToken('user-1', 'short')).toBe(false); + }); + }); +}); diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts index afec211..7c50ab3 100644 --- a/server/src/routes/config.ts +++ b/server/src/routes/config.ts @@ -8,10 +8,13 @@ const router = Router(); */ router.get('/', (req: Request, res: Response) => { try { + // 注意: ICAL_TOKEN はここで返さない。 + // このエンドポイントはログイン前の全ブラウザから取得できるため、 + // 共通トークンを返すと全員分のシフトを誰でも購読できてしまう。 + // 購読URLは GET /api/ical/my-url でユーザーごとに発行する。 const config = { googleClientId: process.env.GOOGLE_CLIENT_ID || '', authorizedEmails: process.env.AUTHORIZED_EMAILS || '', - icalToken: process.env.ICAL_TOKEN || '', }; // 必須設定のバリデーション diff --git a/server/src/routes/ical.ts b/server/src/routes/ical.ts index 2e5112f..99a64c1 100644 --- a/server/src/routes/ical.ts +++ b/server/src/routes/ical.ts @@ -1,9 +1,14 @@ import express from 'express'; import { ICalService } from '../services/ICalService'; +import { deriveUserIcalToken, verifyUserIcalToken } from '../utils/icalToken'; const router = express.Router(); -function validateToken(req: express.Request, res: express.Response): boolean { +/** + * 管理者用トークン(ICAL_TOKEN そのもの)を検証する + * 全ユーザー分のシフトを返すエンドポイントでのみ使用する + */ +function validateAdminToken(req: express.Request, res: express.Response): boolean { const token = process.env.ICAL_TOKEN; if (!token) { res.status(503).json({ error: 'ICAL_TOKEN が設定されていません' }); @@ -18,27 +23,94 @@ function validateToken(req: express.Request, res: express.Response): boolean { function sendIcal(res: express.Response, icsContent: string): void { res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', 'attachment; filename="shifts.ics"'); + // カレンダーアプリからの購読を想定しているため inline で返す + res.setHeader('Content-Disposition', 'inline; filename="shifts.ics"'); res.setHeader('Cache-Control', 'max-age=900'); res.send(icsContent); } /** - * GET /api/ical/all?token= + * GET /api/ical/all?token= * 全ユーザーの全シフトを iCal 形式で返す(管理者用) */ router.get('/all', (req, res) => { - if (!validateToken(req, res)) return; + if (!validateAdminToken(req, res)) return; sendIcal(res, ICalService.generateAll()); }); /** - * GET /api/ical/user/:user_id?token= + * 認可メールアドレス(管理者)かどうかを判定する + */ +function isAuthorizedEmail(email: unknown): boolean { + if (typeof email !== 'string' || email === '') return false; + + const authorized = (process.env.AUTHORIZED_EMAILS || '') + .split(',') + .map(e => e.trim().toLowerCase()) + .filter(e => e !== ''); + + return authorized.includes(email.trim().toLowerCase()); +} + +/** + * GET /api/ical/all-url?email=<管理者のメールアドレス> + * 管理者用(全シフト)の購読URLを返す + */ +router.get('/all-url', (req, res) => { + if (!isAuthorizedEmail(req.query.email)) { + res.status(403).json({ success: false, error: '管理者権限が必要です' }); + return; + } + + const token = process.env.ICAL_TOKEN; + if (!token) { + res.status(503).json({ success: false, error: 'ICAL_TOKEN が設定されていません' }); + return; + } + + res.json({ success: true, data: { url: `/api/ical/all?token=${token}` } }); +}); + +/** + * GET /api/ical/my-url?userId= + * ログインユーザー本人の購読URLを返す + */ +router.get('/my-url', (req, res) => { + const userId = req.query.userId; + if (typeof userId !== 'string' || userId === '') { + res.status(400).json({ success: false, error: 'userId が指定されていません' }); + return; + } + + const token = deriveUserIcalToken(userId); + if (!token) { + res.status(503).json({ success: false, error: 'ICAL_TOKEN が設定されていません' }); + return; + } + + res.json({ + success: true, + data: { url: `/api/ical/user/${encodeURIComponent(userId)}?token=${token}` }, + }); +}); + +/** + * GET /api/ical/user/:user_id?token=<ユーザー個別トークン> * 特定ユーザーのシフトを iCal 形式で返す + * トークンは user_id と対でのみ有効(他人の user_id には使えない) */ router.get('/user/:user_id', (req, res) => { - if (!validateToken(req, res)) return; + if (!process.env.ICAL_TOKEN) { + res.status(503).json({ error: 'ICAL_TOKEN が設定されていません' }); + return; + } + const userId = req.params.user_id as string; + if (!verifyUserIcalToken(userId, req.query.token)) { + res.status(401).json({ error: '認証トークンが無効です' }); + return; + } + sendIcal(res, ICalService.generateForUser(userId)); }); diff --git a/server/src/utils/icalToken.ts b/server/src/utils/icalToken.ts new file mode 100644 index 0000000..2f458fd --- /dev/null +++ b/server/src/utils/icalToken.ts @@ -0,0 +1,46 @@ +import crypto from 'crypto'; + +/** + * iCal 購読トークンのユーティリティ + * + * 購読URLはカレンダーアプリに貼り付けられ、ユーザーの手元に長く残る。 + * そのため全員で共通のトークンを使うと、URL の user_id を書き換えるだけで + * 他人のシフトが読めてしまう。ここではサーバー秘密鍵(ICAL_TOKEN)と + * user_id から HMAC でユーザーごとのトークンを導出し、user_id と対でのみ + * 有効になるようにする。 + * + * - DB マイグレーション不要(保存せず、都度導出する) + * - ICAL_TOKEN を再生成すれば全ユーザーの購読URLを一括失効できる + */ + +/** + * user_id 専用の購読トークンを導出する + * @returns ICAL_TOKEN が未設定の場合は null + */ +export function deriveUserIcalToken(userId: string): string | null { + const secret = process.env.ICAL_TOKEN; + if (!secret) return null; + + return crypto + .createHmac('sha256', secret) + .update(`ical-user:${userId}`) + .digest('base64url'); +} + +/** + * user_id とトークンの組み合わせが正しいか検証する + * タイミング攻撃を避けるため timingSafeEqual で比較する + */ +export function verifyUserIcalToken(userId: string, token: unknown): boolean { + if (typeof token !== 'string' || token.length === 0) return false; + + const expected = deriveUserIcalToken(userId); + if (!expected) return false; + + const expectedBuf = Buffer.from(expected); + const actualBuf = Buffer.from(token); + // timingSafeEqual は長さが違うと例外を投げるため、先に長さを確認する + if (expectedBuf.length !== actualBuf.length) return false; + + return crypto.timingSafeEqual(expectedBuf, actualBuf); +} diff --git a/test/admin-ical-url.test.js b/test/admin-ical-url.test.js new file mode 100644 index 0000000..f31b2f7 --- /dev/null +++ b/test/admin-ical-url.test.js @@ -0,0 +1,93 @@ +/** + * 管理者用iCal購読URL(全シフト)表示のテスト + * + * 回帰防止: /api/config から共通トークンの配布をやめた際、 + * 管理者の購読URL欄が常に「未設定」表示になる不具合が発生した。 + * 購読URLはサーバーから取得する方式に変更されている。 + */ + +describe('管理者用iCal購読URL(全シフト)', () => { + let mockAPI; + + // 実装(js/modules/shifts.js の updateIcalUrl)と同じ挙動 + async function updateIcalUrl() { + const input = document.getElementById('icalAllUrl'); + if (!input) return; + + const currentUser = global.getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await global.API.getAllIcalUrl(currentUser.email); + if (result.success && result.data && result.data.url) { + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + input.value = '(購読URLを取得できませんでした)'; + } + } + + beforeEach(() => { + document.body.innerHTML = ` +
    +
    + + +
    +
    + `; + + mockAPI = { getAllIcalUrl: jest.fn() }; + global.API = mockAPI; + global.getCurrentUser = jest.fn(() => ({ sub: 'dev-admin-001', email: 'admin@example.com' })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('サーバーから取得したURLを表示する', async () => { + mockAPI.getAllIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/all?token=admin-token-abc' } + }); + + await updateIcalUrl(); + + const value = document.getElementById('icalAllUrl').value; + expect(value).toContain('/api/ical/all?token=admin-token-abc'); + expect(value.startsWith('http')).toBe(true); + }); + + test('「ICAL_TOKEN が未設定です」の固定表示にならない', async () => { + mockAPI.getAllIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/all?token=admin-token-abc' } + }); + + await updateIcalUrl(); + + expect(document.getElementById('icalAllUrl').value).not.toContain('ICAL_TOKEN が未設定'); + }); + + test('取得に失敗してもURL欄が壊れない', async () => { + mockAPI.getAllIcalUrl.mockResolvedValue({ success: false, error: 'ICAL_TOKEN 未設定' }); + + await updateIcalUrl(); + + expect(document.getElementById('icalAllUrl').value).toBe('(購読URLを取得できませんでした)'); + }); + + test('通信が失敗してもURL欄が壊れない', async () => { + mockAPI.getAllIcalUrl.mockRejectedValue(new Error('network error')); + + await updateIcalUrl(); + + expect(document.getElementById('icalAllUrl').value).toBe('(購読URLを取得できませんでした)'); + }); +}); diff --git a/test/my-shifts-ical-url.test.js b/test/my-shifts-ical-url.test.js new file mode 100644 index 0000000..f2776d9 --- /dev/null +++ b/test/my-shifts-ical-url.test.js @@ -0,0 +1,224 @@ +/** + * 自分のシフトのiCal購読URL表示のテスト + * + * 目的: 「自分のシフト一覧」タブに表示する購読URLが + * - 本人の user_id とサーバー発行トークンで組み立てられること + * - 共通トークンをフロントで組み立てないこと + * - 取得失敗時にURL欄が壊れないこと + * を検証する + */ + +describe('自分のシフトのiCal購読URL', () => { + let mockAPI; + let mockCurrentUser; + + // 実装(js/modules/shifts.js の updateMyIcalUrl)と同じ挙動 + async function updateMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input) return; + + const currentUser = global.getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await global.API.getMyIcalUrl(currentUser.sub); + if (result.success && result.data && result.data.url) { + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + input.value = '(購読URLを取得できませんでした)'; + } + } + + beforeEach(() => { + document.body.innerHTML = ` +
    +
    + + +
    +
    +
    + `; + + mockCurrentUser = { sub: 'dev-user-001', email: 'a@example.com', name: 'ユーザーA' }; + mockAPI = { getMyIcalUrl: jest.fn() }; + + global.API = mockAPI; + global.getCurrentUser = jest.fn(() => mockCurrentUser); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('サーバーが返したURLを絶対URLにしてURL欄へ表示する', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/user/dev-user-001?token=abc123' } + }); + + await updateMyIcalUrl(); + + const value = document.getElementById('icalMyUrl').value; + expect(value).toContain('/api/ical/user/dev-user-001?token=abc123'); + expect(value.startsWith('http')).toBe(true); + }); + + test('ログイン中のユーザーIDでサーバーに問い合わせる', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/user/dev-user-001?token=abc123' } + }); + + await updateMyIcalUrl(); + + expect(mockAPI.getMyIcalUrl).toHaveBeenCalledWith('dev-user-001'); + }); + + test('URLは全シフト用エンドポイントを指さない', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/user/dev-user-001?token=abc123' } + }); + + await updateMyIcalUrl(); + + expect(document.getElementById('icalMyUrl').value).not.toContain('/ical/all'); + }); + + test('未ログインならサーバーに問い合わせずメッセージを表示する', async () => { + global.getCurrentUser = jest.fn(() => null); + + await updateMyIcalUrl(); + + expect(mockAPI.getMyIcalUrl).not.toHaveBeenCalled(); + expect(document.getElementById('icalMyUrl').value).toBe('(ログインが必要です)'); + }); + + test('サーバーがエラーを返してもURL欄が壊れない', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ success: false, error: 'ICAL_TOKEN 未設定' }); + + await updateMyIcalUrl(); + + expect(document.getElementById('icalMyUrl').value).toBe('(購読URLを取得できませんでした)'); + }); + + test('通信が失敗してもURL欄が壊れない', async () => { + mockAPI.getMyIcalUrl.mockRejectedValue(new Error('network error')); + + await updateMyIcalUrl(); + + expect(document.getElementById('icalMyUrl').value).toBe('(購読URLを取得できませんでした)'); + }); +}); + +describe('購読URLのコピー', () => { + // 実装(js/modules/shifts.js の copyMyIcalUrl)と同じ挙動 + async function copyMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input || !input.value || input.value.startsWith('(')) return; + + const btn = document.querySelector('#my-shifts .ical-copy-btn'); + + try { + await navigator.clipboard.writeText(input.value); + } catch (error) { + input.select(); + if (btn) { + const original = btn.textContent; + btn.textContent = 'Ctrl+Cでコピー'; + setTimeout(() => { btn.textContent = original; }, 3000); + } + return; + } + + if (btn) { + const original = btn.textContent; + btn.textContent = 'コピーしました'; + setTimeout(() => { btn.textContent = original; }, 1500); + } + } + + let writeText; + // test/setup.js の setTimeout スタブはコールバックを即座に実行するため、 + // 「一時的に変わった文言」は関数を抜けた時点では元に戻っている。 + // 戻す直前の文言を記録して検証する。 + let labelsBeforeRevert; + + beforeEach(() => { + labelsBeforeRevert = []; + global.setTimeout = jest.fn((cb) => { + if (typeof cb === 'function') { + const btn = document.querySelector('#my-shifts .ical-copy-btn'); + if (btn) labelsBeforeRevert.push(btn.textContent); + cb(); + } + return 1; + }); + }); + + beforeEach(() => { + document.body.innerHTML = ` +
    +
    + + +
    +
    + `; + document.getElementById('icalMyUrl').select = jest.fn(); + + writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('URL欄の内容をクリップボードにコピーする', async () => { + const url = 'http://localhost:3100/api/ical/user/dev-user-001?token=abc123'; + document.getElementById('icalMyUrl').value = url; + + await copyMyIcalUrl(); + + expect(writeText).toHaveBeenCalledWith(url); + }); + + test('コピー後にボタンの文言が「コピーしました」に変わり、その後元に戻る', async () => { + document.getElementById('icalMyUrl').value = 'http://localhost:3100/api/ical/user/dev-user-001?token=abc123'; + + await copyMyIcalUrl(); + + expect(labelsBeforeRevert).toContain('コピーしました'); + expect(document.querySelector('#my-shifts .ical-copy-btn').textContent).toBe('コピー'); + }); + + test('URLがまだ取得できていないときはコピーしない', async () => { + document.getElementById('icalMyUrl').value = '(購読URLを取得できませんでした)'; + + await copyMyIcalUrl(); + + expect(writeText).not.toHaveBeenCalled(); + }); + + test('クリップボードが使えなくても例外を投げず手動コピーを促す', async () => { + document.getElementById('icalMyUrl').value = 'http://localhost:3100/api/ical/user/dev-user-001?token=abc123'; + writeText.mockRejectedValue(new DOMException('Write permission denied.', 'NotAllowedError')); + jest.spyOn(console, 'error').mockImplementation(); + + await expect(copyMyIcalUrl()).resolves.toBeUndefined(); + + expect(labelsBeforeRevert).toContain('Ctrl+Cでコピー'); + expect(document.getElementById('icalMyUrl').select).toHaveBeenCalled(); + }); +});