Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ S3_FORCE_PATH_STYLE=true
# Production: https://applirank.com
NUXT_PUBLIC_SITE_URL=http://localhost:3000

# Public URL used by preview upsell modal "Upgrade to hosted plan" button
# NUXT_PUBLIC_HOSTED_PLAN_URL=https://applirank.com

# ─── Optional: Demo Mode ────────────────────
# Set to an org slug to make that org read-only (blocks mutations)
# DEMO_ORG_SLUG=demo
# Default seeded slug: applirank-demo
# Railway PR preview seeded slug: applirank-pr-demo
# DEMO_ORG_SLUG=applirank-demo

# ─── Optional: Trusted Proxy ────────────────
# IP address of the reverse proxy (Railway/Cloudflare). When set,
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add

- **Dependency security remediation** — resolved all `npm audit --audit-level=high` findings by upgrading `@aws-sdk/client-s3` (pulling patched `@aws-sdk/xml-builder`) and regenerating lockfile resolution
- **Transitive vulnerability pinning** — added npm `overrides` for `fast-xml-parser`, `minimatch`, `tar`, and `readdir-glob` to keep vulnerable transitive ranges out of the install graph
- **Demo write-protection enforcement** — hardened server demo guard so `POST`/`PATCH`/`PUT`/`DELETE` requests are consistently blocked for the configured demo organization and no longer silently fail open when demo org lookup fails
- **Dashboard preview UX** — write attempts in preview mode now trigger a dedicated upsell modal instead of only inline/API errors, while keeping action buttons visible

### Changed

- **Lockfile hygiene** — refreshed dependency graph with `npm install` + `npm dedupe` to remove stale vulnerable transitive entries
- **Demo env guidance** — `.env.example` demo slug example now matches seeded demo organization slug (`applirank-demo`) to reduce configuration drift

---

Expand Down
3 changes: 3 additions & 0 deletions app/components/ApplyCandidateModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Search, X, UserPlus } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
jobId: string
Expand Down Expand Up @@ -32,6 +33,7 @@ const { data: candidateData, status: searchStatus } = useFetch('/api/candidates'
})

const candidates = computed(() => candidateData.value?.data ?? [])
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

// Apply candidate
const isApplying = ref(false)
Expand All @@ -47,6 +49,7 @@ async function applyCandidate(candidateId: string) {
})
emit('created')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
applyError.value = err.data?.statusMessage ?? 'Failed to apply candidate'
} finally {
isApplying.value = false
Expand Down
3 changes: 3 additions & 0 deletions app/components/ApplyToJobModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { X, Briefcase } from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
candidateId: string
Expand All @@ -18,6 +19,7 @@ const { data: jobData, status: jobFetchStatus } = useFetch('/api/jobs', {
})

const jobs = computed(() => jobData.value?.data ?? [])
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

// Apply to job
const isApplying = ref(false)
Expand All @@ -33,6 +35,7 @@ async function applyToJob(jobId: string) {
})
emit('created')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
applyError.value = err.data?.statusMessage ?? 'Failed to apply to job'
} finally {
isApplying.value = false
Expand Down
6 changes: 6 additions & 0 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
ArrowLeft, AlertTriangle,
} from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

const props = defineProps<{
applicationId: string
Expand All @@ -15,6 +16,8 @@ const emit = defineEmits<{
(e: 'updated'): void
}>()

const { handlePreviewReadOnlyError } = usePreviewReadOnly()

// ─────────────────────────────────────────────
// Tabs
// ─────────────────────────────────────────────
Expand Down Expand Up @@ -114,6 +117,7 @@ async function handleTransition(newStatus: string) {
await refresh()
emit('updated')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to update status')
} finally {
isTransitioning.value = false
Expand Down Expand Up @@ -144,6 +148,7 @@ async function saveNotes() {
emit('updated')
isEditingNotes.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to save notes')
} finally {
isSavingNotes.value = false
Expand Down Expand Up @@ -247,6 +252,7 @@ async function handleDeleteDoc(docId: string) {
await refreshCandidate()
showDocDeleteConfirm.value = null
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
alert(err.data?.statusMessage ?? 'Failed to delete document')
} finally {
isDeletingDoc.value = false
Expand Down
69 changes: 69 additions & 0 deletions app/components/PreviewUpsellModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'

const emit = defineEmits<{
(e: 'close'): void
}>()

const { message } = usePreviewReadOnly()
const config = useRuntimeConfig()

function closeModal() {
emit('close')
}
</script>

<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />

<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>

<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>

<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>

<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>

<div class="flex flex-wrap items-center gap-2">
<a
:href="config.public.hostedPlanUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Upgrade to hosted plan
</a>

<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Self-host on GitHub
</a>
</div>
</div>
</div>
</div>
Comment on lines +18 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Non-navigable modal — missing ARIA roles, keyboard handling, and focus management

The modal is inaccessible to keyboard and screen-reader users. The following gaps all need addressing:

Gap Fix
No role="dialog" / aria-modal="true" on the container Add to the inner div (line 20)
No aria-labelledby linking the title Give <h3> an id, reference it
No Escape key listener @keydown.esc.window="closeModal"
No focus trap Tab cycles outside the modal
Close button has no accessible name Add aria-label="Close dialog"
Focus not moved into modal on open, not restored on close Programmatic focus management via templateRef + watch/onMounted

WAI-ARIA Authoring Practices require: closing on ESC, toggling ARIA attributes, and trapping/restoring focus. A modal dialog should have both role="dialog" and aria-modal="true" so screen readers treat it as a modal.

♿ Proposed minimal fix
+<script setup lang="ts">
 import { Crown, X, Rocket } from 'lucide-vue-next'
+import { onMounted, useTemplateRef } from 'vue'

 const emit = defineEmits<{
   (e: 'close'): void
 }>()

 const { message } = usePreviewReadOnly()
+const dialogRef = useTemplateRef<HTMLDivElement>('dialog')

 function closeModal() {
   emit('close')
 }
+
+function onKeydown(e: KeyboardEvent) {
+  if (e.key === 'Escape') closeModal()
+}
+
+onMounted(() => {
+  dialogRef.value?.focus()
+})
+</script>

 <template>
   <Teleport to="body">
-    <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
+    <div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
       <div class="absolute inset-0 bg-black/50" `@click`="closeModal" />

-      <div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
+      <div
+        ref="dialog"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="upsell-title"
+        tabindex="-1"
+        class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
+      >
         <div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
           <div class="flex items-center gap-2">
             <Crown class="size-5 text-brand-600 dark:text-brand-400" />
-            <h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
+            <h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
           </div>

           <button
+            aria-label="Close dialog"
             class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
             `@click`="closeModal"
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal" />
<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
@click="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
<script setup lang="ts">
import { Crown, X, Rocket } from 'lucide-vue-next'
import { onMounted, useTemplateRef } from 'vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
const { message } = usePreviewReadOnly()
const dialogRef = useTemplateRef<HTMLDivElement>('dialog')
function closeModal() {
emit('close')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeModal()
}
onMounted(() => {
dialogRef.value?.focus()
})
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" `@keydown`="onKeydown">
<div class="absolute inset-0 bg-black/50" `@click`="closeModal" />
<div
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="upsell-title"
tabindex="-1"
class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900 focus:outline-none"
>
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
<div class="flex items-center gap-2">
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
<h3 id="upsell-title" class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
</div>
<button
aria-label="Close dialog"
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
`@click`="closeModal"
>
<X class="size-5" />
</button>
</div>
<div class="space-y-4 px-5 py-5">
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ message }}
</p>
<p class="text-sm text-surface-500 dark:text-surface-400">
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
</p>
<div class="flex flex-wrap items-center gap-2">
<a
href="mailto:sales@applirank.com?subject=Applirank%20Hosted%20Plan"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
>
<Rocket class="size-4" />
Contact sales
</a>
<a
href="https://github.com/applirank/applirank"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
>
Deploy your own
</a>
</div>
</div>
</div>
</div>
</Teleport>
</template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/PreviewUpsellModal.vue` around lines 17 - 64, The modal lacks
ARIA roles, keyboard handling, and focus management: add role="dialog" and
aria-modal="true" to the inner modal container element, give the <h3> a unique
id and reference it via aria-labelledby on that container, add
`@keydown.esc.window`="closeModal" to capture Escape, add aria-label="Close
dialog" to the close <button>, and implement focus management by adding a
template ref (e.g., modalRef) and code that, when the modal opens, saves
document.activeElement, moves focus into the first focusable element inside the
modal, traps Tab/Shift+Tab inside the modal, and restores focus on close using
the existing closeModal method; you can implement the trap with a small utility
in setup() or use a11y helper lib, and wire the watch/onMounted hooks to run
this behavior when the modal becomes visible.

</Teleport>
</template>
20 changes: 13 additions & 7 deletions app/composables/useApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
* Wraps `useFetch('/api/applications/:id')` with a reactive key.
*/
export function useApplication(id: MaybeRefOrGetter<string>) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const applicationId = computed(() => toValue(id))

const { data: application, status, error, refresh } = useFetch(
Expand All @@ -21,13 +22,18 @@ export function useApplication(id: MaybeRefOrGetter<string>) {
notes: string | null
score: number | null
}>) {
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('applications')
return updated
try {
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('applications')
return updated
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

return { application, status, error, refresh, updateApplication }
Expand Down
20 changes: 14 additions & 6 deletions app/composables/useApplications.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for managing the applications list with filtering, pagination, and mutations.
Expand All @@ -9,6 +10,8 @@ export function useApplications(options?: {
candidateId?: Ref<string | undefined> | string
status?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.jobId) && { jobId: toValue(options?.jobId) }),
...(toValue(options?.candidateId) && { candidateId: toValue(options?.candidateId) }),
Expand All @@ -30,12 +33,17 @@ export function useApplications(options?: {
jobId: string
notes?: string
}) {
const created = await $fetch('/api/applications', {
method: 'POST',
body: payload,
})
await refresh()
return created
try {
const created = await $fetch('/api/applications', {
method: 'POST',
body: payload,
})
await refresh()
return created
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

return {
Expand Down
28 changes: 20 additions & 8 deletions app/composables/useCandidate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { MaybeRefOrGetter } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for a single candidate detail with update and delete mutations.
* Wraps `useFetch('/api/candidates/:id')` with a reactive key.
*/
export function useCandidate(id: MaybeRefOrGetter<string>) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const candidateId = computed(() => toValue(id))

const { data: candidate, status, error, refresh } = useFetch(
Expand All @@ -22,18 +24,28 @@ export function useCandidate(id: MaybeRefOrGetter<string>) {
email: string
phone: string | null
}>) {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('candidates')
return updated
try {
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
method: 'PATCH',
body: payload,
})
await refresh()
await refreshNuxtData('candidates')
return updated
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

/** Delete this candidate and navigate back to the list */
async function deleteCandidate() {
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
try {
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
await refreshNuxtData('candidates')
await navigateTo('/dashboard/candidates')
}
Expand Down
27 changes: 20 additions & 7 deletions app/composables/useCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'

/**
* Composable for managing the candidates list with search, pagination, and mutations.
Expand All @@ -7,6 +8,8 @@ import type { Ref } from 'vue'
export function useCandidates(options?: {
search?: Ref<string | undefined> | string
}) {
const { handlePreviewReadOnlyError } = usePreviewReadOnly()

const query = computed(() => ({
...(toValue(options?.search) && { search: toValue(options?.search) }),
}))
Expand All @@ -27,17 +30,27 @@ export function useCandidates(options?: {
email: string
phone?: string
}) {
const created = await $fetch('/api/candidates', {
method: 'POST',
body: payload,
})
await refresh()
return created
try {
const created = await $fetch('/api/candidates', {
method: 'POST',
body: payload,
})
await refresh()
return created
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
}

/** Delete a candidate by ID and refresh the list */
async function deleteCandidate(id: string) {
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
try {
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
} catch (error) {
handlePreviewReadOnlyError(error)
throw error
}
await refresh()
}

Expand Down
Loading