Files
MyIP/frontend/components/advanced-tools/SecurityChecklist.vue
T
Claude 96525d2fc7 Chore: trim the branch — leftovers, redundancy, comments
A pass over every file the branch touched, after the iOS animation hunt
left tries and reverts behind it. Removed what nothing reaches: the
view-transition rule for a transition no code starts, an unreachable
guard in the transition hook, a no-op KeepAlive key branch, a sign-in
guard the menu already enforces, usage-dialog wrappers that only
forwarded to the store, a duplicated modifier-click check and card
filter (isPlainClick / listedTools), a test that hand-rolled
listedTools, a drawer-era fixture parameter. auth-flow.js folded into
auth-redirect.js. Comments cut to what the code cannot say itself —
file headers of a few lines, the measured iOS reasons, non-obvious
orderings — and stale ones fixed (Nav under KeepAlive, Home.vue
reusing DEFAULT_META, "from Nav", enabledCards). AGENTS.md sections
tightened to the code as it is now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:50:16 +00:00

528 lines
27 KiB
Vue

<template>
<div class="security-checklist-section my-4 space-y-4">
<!-- Top note -->
<div class="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<p>{{ t('securitychecklist.Note') }}</p>
<p>{{ t('securitychecklist.Note2') }}</p>
</div>
<!-- Loading state (initial dataset fetch + locale switches); the
chunk phase before it shows ToolLoadingSkeleton instead. -->
<div v-if="!fullList" class="flex justify-center py-12">
<Spinner class="size-6 text-info" />
</div>
<template v-else>
<!-- Top progress panel -->
<Card>
<CardContent class="p-4 md:p-6 space-y-4">
<!-- Title bar -->
<header class="flex items-center justify-between gap-2">
<h3 class="flex items-center gap-2 text-base font-semibold m-0">
<ClipboardList class="size-4 text-muted-foreground" />
{{ t('securitychecklist.Progress') }}
</h3>
<JnTooltip :text="t('securitychecklist.Reset')" side="left">
<Button size="icon" variant="outline" @click="resetAllslugs()"
aria-label="Reset Security Checklist">
<RotateCw />
</Button>
</JnTooltip>
</header>
<!-- Statistical numbers -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="flex flex-col items-start p-3 rounded-md bg-muted/50">
<span class="text-xs text-muted-foreground">{{ t('securitychecklist.alert-total') }}</span>
<span class="text-xl font-semibold tabular-nums">{{ totalItems }}</span>
</div>
<div class="flex flex-col items-start p-3 rounded-md bg-success/10">
<span class="text-xs text-success">{{ t('securitychecklist.Checked') }}</span>
<span class="text-xl font-semibold tabular-nums text-success">{{ checkedItems }}</span>
</div>
<div class="flex flex-col items-start p-3 rounded-md bg-info/10">
<span class="text-xs text-info">{{ t('securitychecklist.Ignored') }}</span>
<span class="text-xl font-semibold tabular-nums text-info">{{ ignoredItems }}</span>
</div>
<div class="flex flex-col items-start p-3 rounded-md bg-muted/50">
<span class="text-xs text-muted-foreground">{{ t('securitychecklist.Unchecked') }}</span>
<span class="text-xl font-semibold tabular-nums">{{ uncheckedItems }}</span>
</div>
</div>
<!-- Left 2/3 category progress bar + right 1/3 priority circular chart -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Each category progress bar stacked (checked + ignored two segments) -->
<div class="md:col-span-2 space-y-2">
<div v-for="item in categories" :key="item" class="flex items-center gap-3 text-sm">
<span class="shrink-0 text-foreground/85 min-w-20 truncate">{{ fullList[item].title
}}</span>
<span class="flex h-2 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar">
<div class="h-full bg-success transition-all"
:style="getProgressStyle(item)('checked')"
:title="t('securitychecklist.Checked')"></div>
<div class="h-full bg-info transition-all"
:style="getProgressStyle(item)('ignored')"
:title="t('securitychecklist.Ignored')"></div>
</span>
</div>
</div>
<!-- 4 circular progress by priority -->
<div class="grid grid-cols-2 gap-2">
<div v-for="item in priorities" :key="item" class="flex justify-center">
<CircleProgressBar v-if="priorityDenom(item) !== 0"
:value="countItems({ action: 'checked', category: 'all', priority: item })"
:max="priorityDenom(item)" :colorFilled="progressColors.filled"
:colorUnfilled="progressColors.filled" :colorBack="progressColors.back"
:size="'90pt'" :percentage="true" :strokeWidth="'10pt'">
<span class="text-xs">{{ t('securitychecklist.' + item) }}</span>
</CircleProgressBar>
<CircleProgressBar v-else :value="1" :max="1" :colorFilled="progressColors.info"
:colorUnfilled="progressColors.filled" :colorBack="progressColors.back"
:size="'90pt'" :strokeWidth="'10pt'">
<span class="text-xs">{{ t('securitychecklist.' + item) }}</span>
<br />
<span class="text-[10px] text-muted-foreground">{{ t('securitychecklist.Ignored')
}}</span>
</CircleProgressBar>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Main body: left category navigation + right details -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Left 1/3: category navigation -->
<nav class="space-y-1.5">
<button v-for="item in categories" :key="item" type="button" @click="changeList(item, true)"
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-md text-sm text-left transition-colors border cursor-pointer"
:class="item === currentList
? 'bg-success/10 border-success/40 text-success font-medium'
: 'bg-card border-border hover:bg-muted/50'">
<span class="flex items-center gap-2 min-w-0">
<component :is="getChecklistIcon(item)" class="size-4 shrink-0" />
<span class="truncate">{{ fullList[item].title }}</span>
</span>
<span class="shrink-0 flex items-center gap-1.5">
<!-- Right: completed 100% display ✓, otherwise display checked/total -->
<CircleCheck v-if="countItems({ action: 'percentage', category: item }) === 100"
class="size-4 text-success" />
<span v-else class="text-xs tabular-nums text-muted-foreground">
{{ countItems({ action: 'checked', category: item }) }}/{{
countItems({ action: 'total', category: item }) -
countItems({ action: 'ignored', category: item })
}}
</span>
</span>
</button>
</nav>
<!-- Right 2/3: current category details -->
<div id="checklist" class="md:col-span-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))]">
<Card>
<CardContent class="p-4 md:p-6 space-y-4">
<!-- Category title + description + expandable intro -->
<header>
<h2 class="flex items-center gap-2 text-xl font-semibold tracking-tight m-0 mb-2">
<component :is="getChecklistIcon(currentList)"
class="size-5 text-muted-foreground" />
{{ fullList[currentList].title }}
</h2>
<Collapsible :open="isCategoryIntroOpen" @update:open="isCategoryIntroOpen = $event">
<span class="flex items-center">
<span class="text-sm text-muted-foreground leading-relaxed">
{{ fullList[currentList].description }}
</span>
<CollapsibleTrigger v-if="fullList[currentList].intro" as-child>
<button type="button"
class="inline-flex items-center ml-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
:aria-expanded="isCategoryIntroOpen"
:aria-label="'Display Info of ' + fullList[currentList].title">
<Info class="size-3.5" />
</button>
</CollapsibleTrigger>
</span>
<CollapsibleContent>
<div
class="mt-2 p-3 rounded-md bg-muted/50 text-sm text-muted-foreground leading-relaxed">
<vue-markdown :source="fullList[currentList].intro" />
</div>
</CollapsibleContent>
</Collapsible>
</header>
<!-- Category progress bar + count -->
<div class="flex items-center gap-3">
<span class="flex h-2 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar"
:aria-label="'Progress for ' + fullList[currentList].title">
<span class="h-full bg-success transition-all"
:style="'width: ' + countItems({ action: 'percentage', category: currentList }) + '%'"></span>
</span>
<span class="text-xs text-muted-foreground tabular-nums shrink-0">
{{ countItems({ action: 'checked', category: currentList }) }}/{{
countItems({ action: 'total', category: currentList }) -
countItems({ action: 'ignored', category: currentList })
}}
<span class="opacity-70">({{ t('securitychecklist.Ignored') }}: {{ countItems({
action: 'ignored', category:
currentList }) }})</span>
</span>
</div>
<!-- Filter Toggle -->
<div class="flex justify-start">
<ToggleGroup v-model="filterTag" type="single" variant="outline"
@update:model-value="(v) => v && filterChecklist(v)">
<ToggleGroupItem value="all" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowAll')">
<ListChecks class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="unchecked" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowUnchecked')">
<Circle class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="checked" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowChecked')">
<CircleCheck class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="ignored" class="flex-1 gap-1.5"
:aria-label="t('securitychecklist.ShowIgnored')">
<CirclePause class="size-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
<!-- Item list -->
<ul class="rounded-lg border bg-card divide-y list-none p-0 m-0">
<li v-for="(item, index) in filterList" :key="item.slug" class="transition-colors"
:class="item.checked ? 'bg-success/10' : 'hover:bg-muted/30'">
<div class="flex items-start gap-3 px-3 py-2.5">
<!-- Status switch button -->
<button type="button" @click="checkItem(item)"
class="shrink-0 mt-0.5 text-muted-foreground cursor-pointer"
:class="{ 'text-success': item.checked, 'text-muted-foreground/60 cursor-not-allowed': item.ignored }"
:disabled="item.ignored" :aria-label="item.checked ? 'Uncheck' : 'Check'">
<component
:is="item.checked ? CircleCheck : item.ignored ? CirclePause : Circle"
class="size-5" />
</button>
<!-- Item name + Info expand -->
<div class="flex-1 min-w-0 text-sm">
<div class="flex items-start gap-1"
:class="{ 'line-through opacity-50': item.ignored }">
<span>{{ item.point }}</span>
<button type="button" @click="toggleChecklistInfo(index)"
class="mt-0.75 shrink-0 text-muted-foreground hover:text-foreground cursor-pointer"
:aria-expanded="!!checklistInfoOpen[index]"
:aria-label="'Display Info of ' + item.point">
<Info class="size-3.5" />
</button>
</div>
</div>
<!-- Priority Badge -->
<Badge class="shrink-0 rounded-full font-normal shadow-none"
:class="priorityBadgeClass(item.priority)">
{{ t('securitychecklist.' + item.priority) }}
</Badge>
<!-- Ignore Switch -->
<Switch class="shrink-0" :model-value="item.ignored"
@update:model-value="() => ignoreItem(item)"
:aria-label="t('securitychecklist.Ignore')" />
</div>
<!-- Info details expand -->
<div v-show="checklistInfoOpen[index]" class="px-3 pb-3 pt-1">
<div class="p-3 rounded-md text-xs text-muted-foreground leading-relaxed">
<vue-markdown :source="item.details" />
</div>
</div>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js';
import { CircleProgressBar } from 'circle-progress.vue';
import VueMarkdown from 'vue-markdown-render';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { JnTooltip } from '@/components/ui/tooltip';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Spinner } from '@/components/ui/spinner';
import {
Banknote,
Circle,
CircleCheck,
CirclePause,
ClipboardList,
Compass,
FileWarning,
HousePlus,
Info,
KeyRound,
Laptop,
ListChecks,
Mail,
MessageCircle,
RotateCw,
ScanFace,
ShieldHalf,
Smartphone,
Users,
} from '@lucide/vue';
// Security checklist icon mapping: directly use slug as key (slug itself is a semantic category name)
const checklistIconMap = {
'authentication': KeyRound,
'web-browsing': Compass,
'email': Mail,
'messaging': MessageCircle,
'social-media': Users,
'networks': ShieldHalf,
'mobile-devices': Smartphone,
'personal-computers': Laptop,
'smart-home': HousePlus,
'personal-finance': Banknote,
'human-aspect': ScanFace,
'physical-security': FileWarning,
};
const getChecklistIcon = (slug) => checklistIconMap[slug] || Info;
const isCategoryIntroOpen = ref(false);
const checklistInfoOpen = ref({});
const toggleChecklistInfo = (index) => {
checklistInfoOpen.value[index] = !checklistInfoOpen.value[index];
};
const { t, locale } = useI18n();
// The checklist dataset is large (~30 KB gzipped per language) and only this tool
// reads it, so it's loaded on demand for the active locale instead of being baked
// into the initial i18n bundle (see frontend/locales/i18n.js). The shared loader
// walks the fallback chain, so a failed chunk degrades to the next language
// instead of a spinner that never stops.
const securityDataLoaders = datasetLoaders(import.meta.glob('../../locales/security-checklist/*.json'));
const securityDataCache = new Map();
const securityChecklist = ref(null);
// Fetch the active locale's dataset and (re)build the list. Nulling fullList first
// surfaces the template's existing loading state during the swap.
const loadSecurityChecklist = async () => {
fullList.value = null;
const loc = locale.value;
const pack = await loadLocaleDataset(securityDataLoaders, loc, securityDataCache);
if (loc !== locale.value) return; // stale load — the locale changed mid-flight
if (!pack) {
console.error('Security checklist dataset unavailable in every locale');
return;
}
securityChecklist.value = pack.data;
fullList.value = initSecurityList(pack.data);
};
const store = useMainStore();
const isDarkMode = computed(() => store.isDarkMode);
const categories = ref([]);
const currentList = ref('authentication');
const slugs = ref({});
const priorities = ['Basic', 'Optional', 'Essential', 'Advanced'];
// Priority Badge color: Basic → action (blue); Essential → success (green);
// Optional → info (sky); Advanced → secondary (neutral);
const priorityBadgeClass = (priority) => {
const map = {
Basic: 'bg-action text-action-foreground border-transparent',
Essential: 'bg-success text-success-foreground border-transparent',
Optional: 'bg-info text-info-foreground border-transparent',
Advanced: 'bg-secondary text-secondary-foreground border-transparent',
};
return map[priority] || '';
};
// CircleProgressBar color: third-party library takes hex string, keep semantic color intention consistent:
// filled use green-500, info (incomplete state) use sky-500, background has neutral value in light/dark mode
const progressColors = computed(() => ({
filled: '#22c55e', // green-500
info: '#0ea5e9', // sky-500
back: isDarkMode.value ? '#262626' : '#e5e5e5', // neutral-800 / neutral-200
}));
// Denominator by priority (total items - ignored)
const priorityDenom = (priority) =>
countItems({ action: 'total', category: 'all', priority }) -
countItems({ action: 'ignored', category: 'all', priority });
// Local persistence
const setLocalSlugs = () => {
localStorage.setItem('securityChecklistSlugs', JSON.stringify(slugs.value));
};
const loadLocalSlugs = () => {
const storedSlugs = localStorage.getItem('securityChecklistSlugs');
if (storedSlugs) {
const parsedSlugs = JSON.parse(storedSlugs);
Object.keys(parsedSlugs).forEach(key => { slugs.value[key] = parsedSlugs[key]; });
}
};
const updateSlugs = (slug, value) => {
slugs.value[slug] = value;
setLocalSlugs();
};
const initSecurityList = (securityChecklist) => {
loadLocalSlugs();
const transformedObject = {};
securityChecklist.forEach(item => {
if (!categories.value.includes(item.slug)) categories.value.push(item.slug);
transformedObject[item.slug] = {
title: item.title,
description: item.description,
intro: item.intro,
checklist: item.checklist.map(checkItem => {
const checkItemSlug = checkItem.slug;
if (slugs.value[checkItemSlug] === undefined) slugs.value[checkItemSlug] = '';
return {
...checkItem,
checked: slugs.value[checkItemSlug] === 'checked',
ignored: slugs.value[checkItemSlug] === 'ignored',
slug: checkItemSlug,
};
}),
};
});
setLocalSlugs();
return transformedObject;
};
const fullList = ref(null);
const resetAllslugs = () => {
trackEvent('SecurityChecklist', 'SecurityChecklist', 'Reset');
for (const slug in slugs.value) slugs.value[slug] = '';
setLocalSlugs();
fullList.value = initSecurityList(securityChecklist.value);
listToShow.value = fullList.value[currentList.value];
filterList.value = listToShow.value.checklist;
filterTag.value = 'all';
};
const filterList = ref({});
const listToShow = ref([]);
const filterTag = ref('all');
const changeList = (listName, shouldScroll = true) => {
const previousList = currentList.value;
listToShow.value = fullList.value[listName];
filterList.value = listToShow.value.checklist;
currentList.value = listName;
filterTag.value = 'all';
if (shouldScroll) {
if (previousList !== currentList.value) {
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
}
// On a narrow screen the details stack below the list: bring them into
// view, clear of the fixed Nav (scroll-mt-* on #checklist).
nextTick(() => {
const el = document.getElementById('checklist');
if (!el) return;
const { top } = el.getBoundingClientRect();
const headerBottom = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (top >= headerBottom && top < window.innerHeight * 0.75) return;
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
}
};
const filterChecklist = (filter) => {
filterTag.value = filter;
if (filter === 'all') filterList.value = listToShow.value.checklist;
else if (filter === 'ignored') filterList.value = listToShow.value.checklist.filter(item => item.ignored);
else if (filter === 'checked') filterList.value = listToShow.value.checklist.filter(item => item.checked);
else if (filter === 'unchecked') filterList.value = listToShow.value.checklist.filter(item => !item.checked && !item.ignored);
};
const ignoreItem = (item) => {
item.ignored = !item.ignored;
item.checked = false;
updateSlugs(item.slug, item.ignored ? 'ignored' : '');
};
const checkItem = (item) => {
if (item.ignored) return;
item.checked = !item.checked;
updateSlugs(item.slug, item.checked ? 'checked' : '');
// Achievement rules (SurfaceCheck / HalfwayThere / FullySecured) live in
// data/achievement-rules.js.
emitAppEvent('securitychecklist:progress', {
checked: checkedItems.value,
total: totalItems.value,
});
};
const countItems = ({ action, category, priority }) => {
const categoriesArr = category === 'all' ? Object.keys(fullList.value) : [category];
const actionMap = {
total: (items) => items.length,
ignored: (items) => items.filter(item => item.ignored).length,
checked: (items) => items.filter(item => item.checked).length,
percentage: (items) => {
const total = items.length;
const ignored = items.filter(item => item.ignored).length;
const checked = items.filter(item => item.checked).length;
const denominator = total - ignored;
return denominator === 0 ? 100 : Math.round((checked / denominator) * 100);
},
};
return categoriesArr.reduce((sum, cat) => {
const checklist = fullList.value[cat]?.checklist;
if (!checklist) return sum;
const filteredItems = priority ? checklist.filter(item => item.priority === priority) : checklist;
return sum + (actionMap[action] || (() => 0))(filteredItems);
}, 0);
};
const totalItems = computed(() => countItems({ action: 'total', category: 'all' }));
const checkedItems = computed(() => countItems({ action: 'checked', category: 'all' }));
const ignoredItems = computed(() => countItems({ action: 'ignored', category: 'all' }));
const uncheckedItems = computed(() => totalItems.value - checkedItems.value - ignoredItems.value);
const getProgressStyle = (category) => {
return (action) =>
`width: ${countItems({ action, category }) / countItems({ action: 'total', category }) * 100}%`;
};
onMounted(async () => {
await loadSecurityChecklist();
setTimeout(() => { changeList('authentication', false); }, 300);
});
// Switching language while the tool is open swaps to that locale's dataset.
watch(locale, async () => {
await loadSecurityChecklist();
changeList(currentList.value, false);
});
</script>