-
Edit
+
+
-
-
-
-
-
- Notifications
-
-
-
-
- {{ toggle.label }}
- {{ toggle.sub }}
-
-
-
-
+
-
-
-
-
-
-
+
+ About You
-
-
-
Loading your profile...
-
-
+
+
+
+
+
+
+ {{ formData.bio?.length || 0 }} / 300
+
+
+
+
+
+
+
+
+
+
+ Visibility
+
+
+
+
+ Show in Member Directory
+ Your profile will appear in the private member
+ directory.
+
+
+
+
+
+
+
+ Board
+
+
+
+
+
+ Shown on your board posts so other members can reach out.
+
+
+
+
+
+
+ No posts yet.
+
+ Visit the Board
+
+ to share what you're seeking or offering.
+
+
+
+ -
+
+
{{ post.title }}
+
{{ postExcerpt(post) }}
+
+
+ Edit
+
+
+
+
+
+
+
+ Notifications
+
+
+
+
+ {{ toggle.label }}
+ {{ toggle.sub }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading your profile...
+
+
-
+
@@ -265,7 +271,7 @@ import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
import { TIMEZONE_OPTIONS } from "~/config/timezones";
definePageMeta({
- middleware: 'auth',
+ middleware: "auth",
});
const { memberData, checkMemberStatus } = useAuth();
@@ -274,27 +280,67 @@ const { posts: myPosts, fetchPosts, deletePost } = useBoardPosts();
const toast = useToast();
const availableGhosts = [
- { value: "disbelieving", label: "Disbelieving", image: "/ghosties/Ghost-Disbelieving.png" },
- { value: "double-take", label: "Double Take", image: "/ghosties/Ghost-Double-Take.png" },
- { value: "exasperated", label: "Exasperated", image: "/ghosties/Ghost-Exasperated.png" },
+ {
+ value: "disbelieving",
+ label: "Disbelieving",
+ image: "/ghosties/Ghost-Disbelieving.png",
+ },
+ {
+ value: "double-take",
+ label: "Double Take",
+ image: "/ghosties/Ghost-Double-Take.png",
+ },
+ {
+ value: "exasperated",
+ label: "Exasperated",
+ image: "/ghosties/Ghost-Exasperated.png",
+ },
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
];
+// Compute current UTC offset for an IANA timezone (DST-aware).
+const utcOffset = (tz) => {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz,
+ timeZoneName: "longOffset",
+ }).formatToParts(new Date());
+ const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
+ // "GMT-05:00" → "UTC-05:00"; "GMT" → "UTC+00:00"
+ if (name === "GMT") return "UTC+00:00";
+ return name.replace("GMT", "UTC");
+ } catch {
+ return "";
+ }
+};
+
// Include the saved timezone as a custom option if it's not in the curated list.
const timezoneItems = computed(() => {
const saved = formData.timeZone;
- const list = [...TIMEZONE_OPTIONS];
- if (saved && !list.some((t) => t.value === saved)) {
- list.unshift({ label: saved, value: saved });
+ const list = TIMEZONE_OPTIONS.map((t) => {
+ const off = utcOffset(t.value);
+ return { ...t, label: off ? `${t.label} (${off})` : t.label };
+ });
+ if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
+ const off = utcOffset(saved);
+ list.unshift({ label: off ? `${saved} (${off})` : saved, value: saved });
}
return list;
});
const notificationToggles = [
- { key: "events", label: "Event reminders", sub: "Get notified about upcoming events" },
- { key: "updates", label: "Community updates", sub: "New posts from members you follow" },
+ {
+ key: "events",
+ label: "Event reminders",
+ sub: "Get notified about upcoming events",
+ },
+ {
+ key: "updates",
+ label: "Community updates",
+ sub: "New posts from members you follow",
+ },
];
const { data: tagsData } = await useFetch("/api/tags");
@@ -321,14 +367,7 @@ const formData = reactive({
location: "",
showInDirectory: true,
craftTags: [],
- craftTagsPrivacy: "members",
boardSlackHandle: "",
- pronounsPrivacy: "members",
- timeZonePrivacy: "members",
- avatarPrivacy: "members",
- studioPrivacy: "members",
- bioPrivacy: "members",
- locationPrivacy: "members",
notifications: {
events: true,
updates: true,
@@ -364,15 +403,6 @@ const loadProfile = () => {
const board = memberData.value.board || {};
formData.boardSlackHandle = board.slackHandle || "";
- const privacy = memberData.value.privacy || {};
- formData.pronounsPrivacy = privacy.pronouns || "members";
- formData.timeZonePrivacy = privacy.timeZone || "members";
- formData.avatarPrivacy = privacy.avatar || "members";
- formData.studioPrivacy = privacy.studio || "members";
- formData.bioPrivacy = privacy.bio || "members";
- formData.locationPrivacy = privacy.location || "members";
- formData.craftTagsPrivacy = privacy.craftTags || "members";
-
const notifs = memberData.value.notifications || {};
formData.notifications.events = notifs.events ?? true;
formData.notifications.updates = notifs.updates ?? true;
@@ -397,7 +427,8 @@ const handleSubmit = async () => {
console.error("Profile save error:", error);
toast.add({
title: "Update failed",
- description: error.data?.statusMessage || error.data?.message || "Please try again.",
+ description:
+ error.data?.statusMessage || error.data?.message || "Please try again.",
color: "error",
});
} finally {
@@ -474,11 +505,6 @@ useHead({
gap: 12px;
}
-/* ---- PRIVACY TOGGLE SPACING ---- */
-.field :deep(.priv) {
- margin-top: 4px;
-}
-
/* ---- FIELD LABELS (distinct from .section-label) ---- */
.field label {
font-size: 11px;
@@ -703,11 +729,6 @@ useHead({
gap: 12px;
}
-/* ---- TIMEZONE SELECT ---- */
-.timezone-select {
- width: 100%;
-}
-
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.row-2 {
@@ -721,3 +742,85 @@ useHead({
}
}
+
+
diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js
index 74ee1f9..e32522b 100644
--- a/server/api/auth/member.get.js
+++ b/server/api/auth/member.get.js
@@ -25,7 +25,6 @@ export default defineEventHandler(async (event) => {
board: member.board,
showInDirectory: member.showInDirectory,
notifications: member.notifications,
- privacy: member.privacy,
createdAt: member.createdAt,
onboarding: member.onboarding,
};
diff --git a/server/api/members/[id].get.js b/server/api/members/[id].get.js
index 02546f2..f5d1512 100644
--- a/server/api/members/[id].get.js
+++ b/server/api/members/[id].get.js
@@ -1,25 +1,8 @@
-import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
-import { connectDB } from "../../utils/mongoose.js";
+import { requireAuth } from "../../utils/auth.js";
export default defineEventHandler(async (event) => {
- await connectDB();
-
- // Check if user is authenticated (optional — works for public and authenticated users)
- const token = getCookie(event, "auth-token");
- let isAuthenticated = false;
-
- if (token) {
- try {
- const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
- if (decoded.memberId) {
- isAuthenticated = true;
- }
- } catch {
- // Invalid token, treat as public
- isAuthenticated = false;
- }
- }
+ await requireAuth(event);
const id = event.context.params.id;
@@ -30,7 +13,7 @@ export default defineEventHandler(async (event) => {
status: "active",
})
.select(
- "name pronouns timeZone avatar studio bio location socialLinks privacy circle craftTags board createdAt memberNumber",
+ "name pronouns timeZone avatar studio bio location socialLinks circle craftTags board createdAt memberNumber",
)
.lean();
@@ -41,42 +24,27 @@ export default defineEventHandler(async (event) => {
});
}
- // Filter fields based on privacy settings
- const privacy = member.privacy || {};
const filtered = {
_id: member._id,
name: member.name,
circle: member.circle,
createdAt: member.createdAt,
memberNumber: member.memberNumber,
- };
-
- // Helper function to check if field should be visible
- const isVisible = (field) => {
- const privacySetting = privacy[field] || "members";
- if (privacySetting === "public") return true;
- if (privacySetting === "members" && isAuthenticated) return true;
- if (privacySetting === "private") return false;
- return false;
- };
-
- // Add fields based on privacy settings
- if (isVisible("avatar")) filtered.avatar = member.avatar;
- if (isVisible("pronouns")) filtered.pronouns = member.pronouns;
- if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
- if (isVisible("studio")) filtered.studio = member.studio;
- if (isVisible("bio")) filtered.bio = member.bio;
- if (isVisible("location")) filtered.location = member.location;
- if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
- if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
-
- filtered.board = {
- slackHandle: member.board?.slackHandle,
+ avatar: member.avatar,
+ pronouns: member.pronouns,
+ timeZone: member.timeZone,
+ studio: member.studio,
+ bio: member.bio,
+ location: member.location,
+ socialLinks: member.socialLinks,
+ craftTags: member.craftTags,
+ board: {
+ slackHandle: member.board?.slackHandle,
+ },
};
return { member: filtered };
} catch (error) {
- // Re-throw NuxtErrors (like the 404 above)
if (error.statusCode) {
throw error;
}
diff --git a/server/api/members/directory.get.js b/server/api/members/directory.get.js
index c0b7abc..8a1713b 100644
--- a/server/api/members/directory.get.js
+++ b/server/api/members/directory.get.js
@@ -1,23 +1,9 @@
-import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import Tag from "../../models/tag.js";
-import { connectDB } from "../../utils/mongoose.js";
+import { requireAuth } from "../../utils/auth.js";
export default defineEventHandler(async (event) => {
- await connectDB();
-
- // Check if user is authenticated
- const token = getCookie(event, "auth-token");
- let isAuthenticated = false;
-
- if (token) {
- try {
- jwt.verify(token, useRuntimeConfig().jwtSecret);
- isAuthenticated = true;
- } catch (err) {
- isAuthenticated = false;
- }
- }
+ await requireAuth(event);
const query = getQuery(event);
const search = query.search || "";
@@ -56,42 +42,28 @@ export default defineEventHandler(async (event) => {
try {
const members = await Member.find(dbQuery)
.select(
- "name pronouns timeZone avatar studio bio location socialLinks privacy circle craftTags board createdAt",
+ "name pronouns timeZone avatar studio bio location socialLinks circle craftTags board createdAt",
)
.sort({ createdAt: -1 })
.lean();
- const filteredMembers = members.map((member) => {
- const privacy = member.privacy || {};
- const filtered = {
- _id: member._id,
- name: member.name,
- circle: member.circle,
- createdAt: member.createdAt,
- };
-
- const isVisible = (field) => {
- const privacySetting = privacy[field] || "members";
- if (privacySetting === "public") return true;
- if (privacySetting === "members" && isAuthenticated) return true;
- return false;
- };
-
- if (isVisible("avatar")) filtered.avatar = member.avatar;
- if (isVisible("pronouns")) filtered.pronouns = member.pronouns;
- if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
- if (isVisible("studio")) filtered.studio = member.studio;
- if (isVisible("bio")) filtered.bio = member.bio;
- if (isVisible("location")) filtered.location = member.location;
- if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
- if (isVisible("craftTags")) filtered.craftTags = member.craftTags;
-
- filtered.board = {
+ const filteredMembers = members.map((member) => ({
+ _id: member._id,
+ name: member.name,
+ circle: member.circle,
+ createdAt: member.createdAt,
+ avatar: member.avatar,
+ pronouns: member.pronouns,
+ timeZone: member.timeZone,
+ studio: member.studio,
+ bio: member.bio,
+ location: member.location,
+ socialLinks: member.socialLinks,
+ craftTags: member.craftTags,
+ board: {
slackHandle: member.board?.slackHandle,
- };
-
- return filtered;
- });
+ },
+ }));
const [craftTags, cooperativeTags] = await Promise.all([
Tag.find({ pool: "craft", active: true }).sort({ label: 1 }).lean(),
@@ -110,6 +82,7 @@ export default defineEventHandler(async (event) => {
},
};
} catch (error) {
+ if (error.statusCode) throw error;
console.error("Directory fetch error:", error);
throw createError({
statusCode: 500,
diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js
index 1cf266a..ea211ab 100644
--- a/server/api/members/profile.patch.js
+++ b/server/api/members/profile.patch.js
@@ -22,18 +22,6 @@ export default defineEventHandler(async (event) => {
"notifications",
];
- // Privacy fields from validated body
- const privacyFields = [
- "pronounsPrivacy",
- "timeZonePrivacy",
- "avatarPrivacy",
- "studioPrivacy",
- "bioPrivacy",
- "locationPrivacy",
- "socialLinksPrivacy",
- "craftTagsPrivacy",
- ];
-
// Build update object from validated data
const updateData = {};
@@ -53,14 +41,6 @@ export default defineEventHandler(async (event) => {
updateData["board.slackHandle"] = body.boardSlackHandle;
}
- // Handle privacy settings
- privacyFields.forEach((privacyField) => {
- if (body[privacyField] !== undefined) {
- const baseField = privacyField.replace("Privacy", "");
- updateData[`privacy.${baseField}`] = body[privacyField];
- }
- });
-
try {
const member = await Member.findByIdAndUpdate(
memberId,
@@ -76,7 +56,7 @@ export default defineEventHandler(async (event) => {
}
// Log which fields were updated
- const changedFields = Object.keys(body).filter(k => body[k] !== undefined && !k.endsWith('Privacy'))
+ const changedFields = Object.keys(body).filter(k => body[k] !== undefined)
if (changedFields.length) {
logActivity(memberId, 'profile_updated', { fields: changedFields })
}
diff --git a/server/models/member.js b/server/models/member.js
index a236184..ff76baa 100644
--- a/server/models/member.js
+++ b/server/models/member.js
@@ -76,50 +76,6 @@ const memberSchema = new mongoose.Schema({
slackHandle: String,
},
- // Privacy settings for profile fields
- privacy: {
- pronouns: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- timeZone: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- avatar: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- studio: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- bio: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- location: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- socialLinks: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- craftTags: {
- type: String,
- enum: ["members", "private"],
- default: "members",
- },
- },
-
notifications: {
events: { type: Boolean, default: true },
updates: { type: Boolean, default: true },
diff --git a/server/utils/schemas.js b/server/utils/schemas.js
index e1012f7..abcf6ec 100644
--- a/server/utils/schemas.js
+++ b/server/utils/schemas.js
@@ -1,13 +1,6 @@
import * as z from 'zod'
import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
-// Binary privacy: 'members' = visible to signed-in members, 'private' = hidden.
-// Legacy 'public' is accepted from old clients and coerced to 'members'.
-const privacyEnum = z.preprocess(
- (v) => (v === 'public' ? 'members' : v),
- z.enum(['members', 'private'])
-)
-
export const emailSchema = z.object({
email: z.string().trim().toLowerCase().email()
})
@@ -37,15 +30,7 @@ export const memberProfileUpdateSchema = z.object({
events: z.boolean().optional(),
updates: z.boolean().optional()
}).optional(),
- pronounsPrivacy: privacyEnum.optional(),
- timeZonePrivacy: privacyEnum.optional(),
- avatarPrivacy: privacyEnum.optional(),
- studioPrivacy: privacyEnum.optional(),
- bioPrivacy: privacyEnum.optional(),
- locationPrivacy: privacyEnum.optional(),
- socialLinksPrivacy: privacyEnum.optional(),
craftTags: z.array(z.string().max(100)).max(16).optional(),
- craftTagsPrivacy: privacyEnum.optional(),
boardSlackHandle: z.string().max(200).optional()
})
diff --git a/tests/server/api/validation.test.js b/tests/server/api/validation.test.js
index a94670e..9433a5c 100644
--- a/tests/server/api/validation.test.js
+++ b/tests/server/api/validation.test.js
@@ -175,20 +175,11 @@ describe('memberProfileUpdateSchema', () => {
expect(result.data).not.toHaveProperty('status')
})
- it('validates privacy enum values', () => {
- const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'invalid' })
- expect(result.success).toBe(false)
- })
-
- it('accepts valid binary privacy values', () => {
- expect(memberProfileUpdateSchema.safeParse({ bioPrivacy: 'members' }).success).toBe(true)
- expect(memberProfileUpdateSchema.safeParse({ bioPrivacy: 'private' }).success).toBe(true)
- })
-
- it('coerces legacy "public" privacy value to "members"', () => {
- const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'public' })
+ it('strips unknown privacy fields from profile update', () => {
+ const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'members', bio: 'hello' })
expect(result.success).toBe(true)
- expect(result.data.bioPrivacy).toBe('members')
+ expect(result.data).not.toHaveProperty('bioPrivacy')
+ expect(result.data.bio).toBe('hello')
})
})