Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 などで生成)
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,27 @@ ICAL_TOKEN=生成したトークン文字列

### エンドポイント

| URL | 用途 |
|-----|------|
| `GET /api/ical/all?token=<token>` | 全スタッフのシフト(管理者用) |
| `GET /api/ical/user/{user_id}?token=<token>` | 特定ユーザーのシフト |
| URL | 用途 | トークン |
|-----|------|----------|
| `GET /api/ical/all?token=<token>` | 全スタッフのシフト(管理者用) | `.env` の `ICAL_TOKEN` |
| `GET /api/ical/user/{user_id}?token=<token>` | 特定ユーザーのシフト | ユーザーごとの導出トークン |
| `GET /api/ical/my-url?userId=<user_id>` | 本人の購読URLを発行 | 不要 |
| `GET /api/ical/all-url?email=<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カレンダーに表示する」を参照してください。

### カレンダーアプリへの登録

Expand All @@ -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が発行されます。

## 使用技術

Expand Down
Binary file added docs/images/my-shifts-ical-copied.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/my-shifts-ical-url.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 88 additions & 6 deletions help.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -286,9 +295,10 @@ <h3>📑 目次</h3>
<li><a href="#profile-setup">3. プロフィール設定</a></li>
<li><a href="#shift-request">4. シフト申請</a></li>
<li><a href="#my-shifts">5. 自分のシフト確認</a></li>
<li><a href="#shift-calendar">6. シフトカレンダーの見方</a></li>
<li><a href="#faq">7. よくある質問</a></li>
<li><a href="#troubleshooting">8. トラブルシューティング</a></li>
<li><a href="#google-calendar">6. Googleカレンダーに表示する</a></li>
<li><a href="#shift-calendar">7. シフトカレンダーの見方</a></li>
<li><a href="#faq">8. よくある質問</a></li>
<li><a href="#troubleshooting">9. トラブルシューティング</a></li>
</ul>
</div>

Expand Down Expand Up @@ -489,8 +499,80 @@ <h3>シフトの削除</h3>
</div>
</div>

<div class="section" id="google-calendar">
<h2>6. Googleカレンダーに表示する</h2>

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

<div class="info-box">
<strong>💡 ポイント</strong>
<p>表示されるのは<strong>自分のシフトだけ</strong>です。他の人のシフトは表示されません。スマートフォンのGoogleカレンダーアプリにも、同じアカウントなら自動で表示されます。</p>
</div>

<h3>設定の手順</h3>

<div class="steps">
<div class="step">
<strong>1.「自分のシフト一覧」タブを開く</strong>
<p>ログインしたあと、画面上部の「自分のシフト一覧」タブをクリックします。一覧の上に「Googleカレンダーに表示する(自分のシフトのみ)」と書かれた青い枠があります。</p>
<img src="docs/images/my-shifts-ical-url.png" alt="「自分のシフト一覧」タブの上部に表示される購読URLの欄" class="help-screenshot">
</div>

<div class="step">
<strong>2.「コピー」ボタンを押す</strong>
<p>青い枠の右にある「コピー」ボタンをクリックします。ボタンの文字が「コピーしました」に変われば、URLがコピーできています。</p>
<img src="docs/images/my-shifts-ical-copied.png" alt="コピーボタンを押すと「コピーしました」に変わる" class="help-screenshot">
</div>

<div class="step">
<strong>3. Googleカレンダーを開く</strong>
<p>パソコンのブラウザで <a href="https://calendar.google.com/" target="_blank" rel="noopener">calendar.google.com</a> を開きます。<strong>この設定はパソコンから行ってください。</strong>スマートフォンのアプリからは追加できませんが、パソコンで設定すればスマートフォンにも自動的に表示されます。</p>
</div>

<div class="step">
<strong>4.「他のカレンダー」の「+」をクリック</strong>
<p>画面左側の下のほうにある「他のカレンダー」の横の「+」をクリックし、メニューから「<strong>URLで追加</strong>」を選びます。</p>
</div>

<div class="step">
<strong>5. コピーしたURLを貼り付ける</strong>
<p>「カレンダーのURL」の入力欄に、手順2でコピーしたURLを貼り付けます(Windowsなら Ctrl+V、Macなら ⌘+V)。そのあと「<strong>カレンダーを追加</strong>」をクリックします。</p>
</div>

<div class="step">
<strong>6. 完了</strong>
<p>左側のカレンダー一覧に「シフト管理」が追加され、自分のシフトが予定として表示されます。</p>
</div>
</div>

<h3>反映されるまでの時間について</h3>

<div class="warning-box">
<strong>⚠️ すぐには反映されません</strong>
<p>シフトを申請・削除しても、Googleカレンダーに反映されるまで<strong>最大で24時間ほどかかることがあります</strong>。これはGoogleカレンダーが情報を取りに来るタイミングによるもので、設定の失敗ではありません。</p>
<p>急いで最新の状態を確認したいときは、このシフト管理システムの「自分のシフト一覧」タブを見てください。こちらは常に最新です。</p>
</div>

<h3>URLの取り扱いについて</h3>

<div class="warning-box">
<strong>⚠️ URLは他の人に教えないでください</strong>
<p>このURLには、あなた専用の合言葉(トークン)が含まれています。URLを知っている人は誰でもあなたのシフトを見られてしまうため、SNSに貼ったり、友達に送ったりしないでください。</p>
<p>もし他の人に知られてしまった場合は、管理者に連絡してください。</p>
</div>

<h3>うまくいかないときは</h3>

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

<div class="section" id="shift-calendar">
<h2>6. シフトカレンダーの見方</h2>
<h2>7. シフトカレンダーの見方</h2>

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

Expand All @@ -513,7 +595,7 @@ <h3>日付の色について</h3>
</div>

<div class="section" id="faq">
<h2>7. よくある質問</h2>
<h2>8. よくある質問</h2>

<div class="faq-item">
<div class="faq-question">Q: シフトを間違えて申請してしまいました。修正できますか?</div>
Expand All @@ -537,7 +619,7 @@ <h2>7. よくある質問</h2>
</div>

<div class="section" id="troubleshooting">
<h2>8. トラブルシューティング</h2>
<h2>9. トラブルシューティング</h2>

<h3>ログインできない場合</h3>
<ul>
Expand Down
11 changes: 11 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2799,6 +2804,12 @@
</div>

<div id="my-shifts" class="tab-content">
<div class="ical-url-box">
<label>Googleカレンダーに表示する(自分のシフトのみ)</label>
<input type="text" id="icalMyUrl" readonly placeholder="読み込み中...">
<button class="ical-copy-btn" onclick="copyMyIcalUrl()">コピー</button>
<a class="ical-help-link" href="help.html#google-calendar" target="_blank" rel="noopener">使い方</a>
</div>
<div id="myShiftsContent">
<!-- 自分のシフト一覧が動的に生成されます -->
</div>
Expand Down
14 changes: 14 additions & 0 deletions js/modules/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions js/modules/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ async function showProfile(profileData) {
]);
// 初期表示
displayMyShifts(document.getElementById('myShiftsContent'), getCurrentUserShifts());
updateMyIcalUrl();

// 特別シフト募集お知らせを表示
displaySpecialShiftAnnouncement();
Expand Down
2 changes: 0 additions & 2 deletions js/modules/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const config = {
// サーバーから取得する設定(初期値)
GOOGLE_CLIENT_ID: '',
AUTHORIZED_EMAILS: '',
ICAL_TOKEN: '',

// サーバーURL(静的設定)
API_BASE_URL: (() => {
Expand All @@ -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 {
Expand Down
81 changes: 75 additions & 6 deletions js/modules/shifts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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を取得できませんでした)';
}
}

Expand All @@ -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();
Expand All @@ -160,6 +227,8 @@ async function loadMyShifts() {
const container = document.getElementById('myShiftsContent');
if (!container) return;

updateMyIcalUrl();

const currentUser = getCurrentUser();
if (!currentUser) {
container.innerHTML = '<p>ログインが必要です。</p>';
Expand Down
Loading
Loading