Skip to content

Commit fc2708f

Browse files
authored
Merge pull request #157 from reqcore-inc/fix/issues
Fix issues #156-153
2 parents 4d11161 + 0eec67a commit fc2708f

57 files changed

Lines changed: 1618 additions & 313 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000
9797
# Display name for the SSO button (default: "SSO")
9898
# OIDC_PROVIDER_NAME=Company SSO
9999

100+
# ─── Optional: Transactional Email ───────────────────────────────────────────
101+
# Reqcore logs emails to the console by default (safe for local dev).
102+
# For production, configure one of the providers below.
103+
# SMTP takes priority over Resend when SMTP_HOST is set.
104+
105+
# Option A: SMTP (recommended for self-hosted / enterprise setups)
106+
# Supports any SMTP server: Postfix, Gmail, Exchange, Mailcow, Mailu, etc.
107+
# SMTP_HOST=smtp.example.com
108+
# SMTP_PORT=587
109+
# SMTP_USER=reqcore@example.com
110+
# SMTP_PASS=your-smtp-password
111+
# SMTP_FROM="Reqcore <noreply@example.com>"
112+
# SMTP_SECURE=false # true = implicit TLS (port 465), false = STARTTLS (port 587)
113+
114+
# Option B: Resend (free tier: 3,000 emails/month — resend.com)
115+
# RESEND_API_KEY=re_xxxxxxxxxxxx
116+
# RESEND_FROM_EMAIL="Reqcore <noreply@yourcompany.com>"
117+
100118
# ─── Optional: Social Sign-In (Google, GitHub, Microsoft) ────────────────────
101119
# Enable social login buttons on the sign-in and sign-up pages.
102120
# Each provider requires both CLIENT_ID and CLIENT_SECRET to be set.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ test-results/
2828
playwright-report/
2929
blob-report/
3030
playwright/.cache/
31+
.playwright-mcp/
3132

3233

3334
*.code-workspace

SELF-HOSTING.md

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,37 @@ docker compose up --build -d
440440

441441
By default, Reqcore logs email content to the console (useful for development). For production, configure a transactional email service.
442442

443-
### Using Resend (Recommended)
443+
**Priority:** When `SMTP_HOST` is set, SMTP is used. Otherwise, if `RESEND_API_KEY` is set, Resend is used. If neither is configured, emails are logged to the console.
444+
445+
### Option A: SMTP (recommended for self-hosted setups)
446+
447+
SMTP works with any mail server — Postfix, Gmail, Exchange, Mailcow, Mailu, etc. No external service dependency.
448+
449+
1. Add to your `.env` file:
450+
451+
```bash
452+
SMTP_HOST=smtp.example.com
453+
SMTP_PORT=587
454+
SMTP_USER=reqcore@example.com
455+
SMTP_PASS=your-smtp-password
456+
SMTP_FROM="Reqcore <noreply@example.com>"
457+
SMTP_SECURE=false # true for implicit TLS (port 465), false for STARTTLS (port 587)
458+
```
459+
460+
2. Restart: `docker compose up --build -d`
461+
462+
**Common setups:**
463+
464+
| Provider | SMTP_HOST | SMTP_PORT | SMTP_SECURE |
465+
|---|---|---|---|
466+
| Gmail (App Password) | `smtp.gmail.com` | `587` | `false` |
467+
| Outlook / Office 365 | `smtp.office365.com` | `587` | `false` |
468+
| Mailcow / Mailu | your server hostname | `587` | `false` |
469+
| Custom Postfix | your server hostname | `587` or `465` | `false` / `true` |
470+
471+
> For Gmail, generate an [App Password](https://support.google.com/accounts/answer/185833) — your regular Gmail password will not work.
472+
473+
### Option B: Resend
444474

445475
1. Sign up at [resend.com](https://resend.com) (free tier: 3,000 emails/month)
446476
2. Verify your sending domain
@@ -454,11 +484,6 @@ RESEND_FROM_EMAIL="Reqcore <noreply@yourcompany.com>"
454484

455485
5. Restart: `docker compose up --build -d`
456486

457-
This enables:
458-
- Team member invitation emails
459-
- Interview scheduling notifications
460-
- Candidate communication emails
461-
462487
---
463488

464489
## Security Best Practices

app/components/AppTopBar.vue

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,22 @@ watch(() => route.path, () => {
144144
showGetStartedMenu.value = false
145145
})
146146
147+
// ─────────────────────────────────────────────
148+
// New Job button
149+
// ─────────────────────────────────────────────
150+
151+
const newJobResetSignal = useState('new-job-reset-signal', () => 0)
152+
153+
function handleNewJobClick() {
154+
const newJobPath = localePath('/dashboard/jobs/new')
155+
if (route.path === newJobPath) {
156+
// Already on the page: signal the wizard to reset instead of navigating
157+
newJobResetSignal.value++
158+
} else {
159+
navigateTo(newJobPath)
160+
}
161+
}
162+
147163
// Close user menu on outside click
148164
const userMenuRef = useTemplateRef<HTMLElement>('userMenuRoot')
149165
function onClickOutsideUser(e: MouseEvent) {
@@ -264,13 +280,13 @@ onUnmounted(() => {
264280
</div>
265281

266282
<!-- New Job button (desktop) -->
267-
<NuxtLink
268-
:to="$localePath('/dashboard/jobs/new')"
269-
class="hidden sm:inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-[13px] font-semibold text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700 hover:shadow-md hover:shadow-brand-600/25 active:bg-brand-800 transition-all duration-200 no-underline"
283+
<button
284+
class="hidden sm:inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-[13px] font-semibold text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700 hover:shadow-md hover:shadow-brand-600/25 active:bg-brand-800 transition-all duration-200 border-0 cursor-pointer"
285+
@click="handleNewJobClick"
270286
>
271287
<Plus class="size-3.5" />
272288
New Job
273-
</NuxtLink>
289+
</button>
274290

275291
<!-- Org Switcher -->
276292
<div class="hidden lg:block ml-1">
@@ -494,13 +510,13 @@ onUnmounted(() => {
494510
</span>
495511
</NuxtLink>
496512

497-
<NuxtLink
498-
:to="$localePath('/dashboard/jobs/new')"
499-
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors no-underline sm:hidden mt-1"
513+
<button
514+
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors sm:hidden mt-1 border-0 cursor-pointer w-full"
515+
@click="handleNewJobClick(); showMobileMenu = false"
500516
>
501517
<Plus class="size-4" />
502518
New Job
503-
</NuxtLink>
519+
</button>
504520

505521
<!-- Get Started CTA (demo mode, mobile) -->
506522
<template v-if="isDemo">

app/components/ApplyCandidateModal.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const { data: candidateData, status: searchStatus } = useFetch('/api/candidates'
3737
3838
const candidates = computed(() => candidateData.value?.data ?? [])
3939
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
40+
const { formatCandidateName } = useOrgSettings()
4041
4142
// Apply candidate
4243
const isApplying = ref(false)
@@ -117,7 +118,7 @@ async function applyCandidate(candidateId: string) {
117118
>
118119
<div class="min-w-0">
119120
<p class="text-sm font-medium text-surface-900 dark:text-surface-100 truncate">
120-
{{ c.firstName }} {{ c.lastName }}
121+
{{ formatCandidateName(c) }}
121122
</p>
122123
<p class="text-xs text-surface-400 truncate">{{ c.email }}</p>
123124
</div>

app/components/CandidateDetailSidebar.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const emit = defineEmits<{
1919
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
2020
const toast = useToast()
2121
const { track } = useTrack()
22+
const { formatCandidateName } = useOrgSettings()
2223
2324
// Detect if the job sub-nav bar is visible (adds 40px / 2.5rem)
2425
const route = useRoute()
@@ -467,7 +468,7 @@ function formatInterviewDate(dateStr: string) {
467468
</div>
468469
<div class="min-w-0">
469470
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-50 truncate">
470-
{{ application.candidate.firstName }} {{ application.candidate.lastName }}
471+
{{ formatCandidateName(application.candidate) }}
471472
</h2>
472473
<div class="flex items-center gap-3 text-sm text-surface-500 dark:text-surface-400">
473474
<a
@@ -617,7 +618,7 @@ function formatInterviewDate(dateStr: string) {
617618
<div>
618619
<dt class="text-xs font-medium text-surface-400 dark:text-surface-500 mb-1">Name</dt>
619620
<dd class="text-surface-800 dark:text-surface-200 font-medium">
620-
{{ application.candidate.firstName }} {{ application.candidate.lastName }}
621+
{{ formatCandidateName(application.candidate) }}
621622
</dd>
622623
</div>
623624
<div>

app/components/InterviewEmailModal.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const emit = defineEmits<{
1616
}>()
1717
1818
const { templates, createTemplate, deleteTemplate, sendInvitation } = useEmailTemplates()
19+
const { formatPersonName } = useOrgSettings()
1920
2021
// ─── System templates (from shared utility — auto-imported) ────
2122
@@ -171,7 +172,7 @@ const canSend = computed(() => {
171172
Send Interview Invitation
172173
</h2>
173174
<p class="text-xs text-surface-500 dark:text-surface-400">
174-
to {{ interview.candidateFirstName }} {{ interview.candidateLastName }} · {{ interview.candidateEmail }}
175+
to {{ formatPersonName(interview.candidateFirstName, interview.candidateLastName) }} · {{ interview.candidateEmail }}
175176
</p>
176177
</div>
177178
</div>

app/components/PipelineCard.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const transitionClasses: Record<string, string> = {
3333
hired: 'text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900',
3434
rejected: 'text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950',
3535
}
36+
37+
const { formatPersonName, formatDateTime } = useOrgSettings()
3638
</script>
3739

3840
<template>
@@ -42,7 +44,7 @@ const transitionClasses: Record<string, string> = {
4244
class="block mb-2 group"
4345
>
4446
<h4 class="text-sm font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors truncate">
45-
{{ candidateFirstName }} {{ candidateLastName }}
47+
{{ formatPersonName(candidateFirstName, candidateLastName) }}
4648
</h4>
4749
<div class="flex items-center gap-2 text-xs text-surface-400 mt-0.5">
4850
<a
@@ -60,7 +62,7 @@ const transitionClasses: Record<string, string> = {
6062
<div class="flex items-center justify-between text-xs text-surface-400">
6163
<span class="inline-flex items-center gap-1">
6264
<Calendar class="size-3" />
63-
{{ new Date(createdAt).toLocaleDateString() }}
65+
{{ formatDateTime(createdAt) }}
6466
</span>
6567
<span v-if="score != null" class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset"
6668
:class="score >= 75

app/components/SettingsMobileNav.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import {
3-
Building2, Users, UserCircle, ChevronLeft, Plug, Brain, ShieldCheck,
3+
Building2, Users, UserCircle, ChevronLeft, Plug, Brain, ShieldCheck, Globe,
44
} from 'lucide-vue-next'
55
66
const route = useRoute()
@@ -13,6 +13,12 @@ const settingsNav = [
1313
icon: Building2,
1414
exact: true,
1515
},
16+
{
17+
label: 'Localization',
18+
to: '/dashboard/settings/localization',
19+
icon: Globe,
20+
exact: true,
21+
},
1622
{
1723
label: 'Members',
1824
to: '/dashboard/settings/members',

app/components/SettingsSidebar.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import {
3-
Building2, Users, UserCircle, ChevronLeft, Settings, Plug, Brain, ShieldCheck,
3+
Building2, Users, UserCircle, ChevronLeft, Settings, Plug, Brain, ShieldCheck, Globe,
44
} from 'lucide-vue-next'
55
66
const route = useRoute()
@@ -14,6 +14,13 @@ const settingsNav = [
1414
icon: Building2,
1515
exact: true,
1616
},
17+
{
18+
label: 'Localization',
19+
description: 'Names & date formats',
20+
to: '/dashboard/settings/localization',
21+
icon: Globe,
22+
exact: true,
23+
},
1724
{
1825
label: 'Members',
1926
description: 'Team & invitations',

0 commit comments

Comments
 (0)