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
12 changes: 10 additions & 2 deletions docs/USER_SETTINGS_CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,19 @@ Settings backup and restore:

Permission-based access control:
- Per-setting edit permissions
- Capability flags for different operations
- Capability flags for different operations (including `canEditPollSettings` for custom poll preferences)
- Extensible for future features
- Role-based support (future enhancement)

### 5. Migration Support
### 5. Poll Creation Preferences

The system includes support for user-configurable default preferences for interactive polls:
- `pollCreationEnabled`: Master toggle for creating polls in classes, study groups, or discussions.
- `defaultPollDuration`: Active duration of created polls (1 to 30 days).
- `allowAnonymousVoting`: Default setting for enabling anonymous votes.
- `pollResultsVisibility`: Control who can view the voting results ('always' | 'after_voting' | 'after_ended').

### 6. Migration Support

Schema version management:
- Automatic migration between versions
Expand Down
6 changes: 6 additions & 0 deletions src/app/profile/profile-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export const settingsPreferences: PreferenceOption[] = [
description: 'Enable offline learning capabilities',
enabled: true,
},
{
id: 'poll-creation',
label: 'Poll Creation',
description: 'Allow creating interactive polls in study groups and courses',
enabled: true,
},
];

export const achievements: Achievement[] = [
Expand Down
80 changes: 80 additions & 0 deletions src/lib/settings/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ describe('SettingsService', () => {
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('notificationsEnabled'))).toBe(true);
});

it('rejects invalid poll duration (below minimum)', () => {
const invalidSettings = { ...createDefaultSettings(), defaultPollDuration: 0 };
const result = SettingsService.validateSettings(invalidSettings);

expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('defaultPollDuration'))).toBe(true);
});

it('rejects invalid poll duration (above maximum)', () => {
const invalidSettings = { ...createDefaultSettings(), defaultPollDuration: 31 };
const result = SettingsService.validateSettings(invalidSettings);

expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('defaultPollDuration'))).toBe(true);
});

it('rejects invalid poll results visibility values', () => {
const invalidSettings = { ...createDefaultSettings(), pollResultsVisibility: 'none' as any };
const result = SettingsService.validateSettings(invalidSettings);

expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('pollResultsVisibility'))).toBe(true);
});
});

// ── createStoreState ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -289,6 +313,41 @@ describe('SettingsService', () => {
const result = SettingsService.validateSettingValue('requireSignatureOnCertificates', true);
expect(result.valid).toBe(true);
});

it('validates correct pollCreationEnabled value', () => {
const result = SettingsService.validateSettingValue('pollCreationEnabled', true);
expect(result.valid).toBe(true);
});

it('rejects invalid pollCreationEnabled value', () => {
const result = SettingsService.validateSettingValue('pollCreationEnabled', 'yes');
expect(result.valid).toBe(false);
});

it('validates correct defaultPollDuration value', () => {
const result = SettingsService.validateSettingValue('defaultPollDuration', 7);
expect(result.valid).toBe(true);
});

it('rejects invalid defaultPollDuration value', () => {
const result = SettingsService.validateSettingValue('defaultPollDuration', 100);
expect(result.valid).toBe(false);
});

it('validates correct allowAnonymousVoting value', () => {
const result = SettingsService.validateSettingValue('allowAnonymousVoting', false);
expect(result.valid).toBe(true);
});

it('validates correct pollResultsVisibility value', () => {
const result = SettingsService.validateSettingValue('pollResultsVisibility', 'after_voting');
expect(result.valid).toBe(true);
});

it('rejects invalid pollResultsVisibility value', () => {
const result = SettingsService.validateSettingValue('pollResultsVisibility', 'nobody');
expect(result.valid).toBe(false);
});
});

// ── exportSettings ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -406,6 +465,7 @@ describe('SettingsService', () => {
expect(capabilities).toHaveProperty('canEditPrefetching');
expect(capabilities).toHaveProperty('canEditReducedMotion');
expect(capabilities).toHaveProperty('canEditElectronicSignature');
expect(capabilities).toHaveProperty('canEditPollSettings');
expect(capabilities).toHaveProperty('canExportSettings');
expect(capabilities).toHaveProperty('canImportSettings');
expect(capabilities).toHaveProperty('canSyncSettings');
Expand Down Expand Up @@ -472,6 +532,26 @@ describe('SettingsService', () => {
const result = SettingsService.canEditSetting('requireSignatureOnCertificates');
expect(result).toBe(true);
});

it('allows editing pollCreationEnabled', () => {
const result = SettingsService.canEditSetting('pollCreationEnabled');
expect(result).toBe(true);
});

it('allows editing defaultPollDuration', () => {
const result = SettingsService.canEditSetting('defaultPollDuration');
expect(result).toBe(true);
});

it('allows editing allowAnonymousVoting', () => {
const result = SettingsService.canEditSetting('allowAnonymousVoting');
expect(result).toBe(true);
});

it('allows editing pollResultsVisibility', () => {
const result = SettingsService.canEditSetting('pollResultsVisibility');
expect(result).toBe(true);
});
});

// ── migrateSettings ──────────────────────────────────────────────────────
Expand Down
10 changes: 8 additions & 2 deletions src/lib/settings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class SettingsService {
const parsed = appSettingsSchema.safeParse(data);

if (!parsed.success) {
parsed.error.errors.forEach((err) => {
parsed.error.errors.forEach((err: any) => {
errors.push(`${err.path.join('.')}: ${err.message}`);
});
return {
Expand Down Expand Up @@ -131,7 +131,7 @@ export class SettingsService {

return {
valid: false,
error: result.errors.find((e) => e.includes(key)) || 'Invalid value',
error: result.errors.find((e) => e.includes(String(key))) || 'Invalid value',
};
} catch {
return {
Expand Down Expand Up @@ -230,6 +230,7 @@ export class SettingsService {
canEditPrefetching: boolean;
canEditReducedMotion: boolean;
canEditElectronicSignature: boolean;
canEditPollSettings: boolean;
canExportSettings: boolean;
canImportSettings: boolean;
canSyncSettings: boolean;
Expand All @@ -242,6 +243,7 @@ export class SettingsService {
canEditPrefetching: true,
canEditReducedMotion: true,
canEditElectronicSignature: true,
canEditPollSettings: true,
canExportSettings: true,
canImportSettings: true,
canSyncSettings: true,
Expand All @@ -265,6 +267,10 @@ export class SettingsService {
electronicSignatureEnabled: 'canEditElectronicSignature',
signatureName: 'canEditElectronicSignature',
requireSignatureOnCertificates: 'canEditElectronicSignature',
pollCreationEnabled: 'canEditPollSettings',
defaultPollDuration: 'canEditPollSettings',
allowAnonymousVoting: 'canEditPollSettings',
pollResultsVisibility: 'canEditPollSettings',
};

return capabilities[permissionMap[key]] || false;
Expand Down
12 changes: 12 additions & 0 deletions src/lib/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export type ThemePreference = z.infer<typeof themePreferenceSchema>;
* - `electronicSignatureEnabled` — Master toggle for electronic signature on authenticated actions.
* - `signatureName` — Full name used as the typed electronic signature (max 100 chars).
* - `requireSignatureOnCertificates` — Prompt the user to confirm their signature before a certificate is issued.
* - `pollCreationEnabled` — Master toggle for creating interactive polls in classes or study groups.
* - `defaultPollDuration` — Default poll duration in days (1 to 30 days).
* - `allowAnonymousVoting` — Toggle to allow participants to vote anonymously by default.
* - `pollResultsVisibility` — Default visibility of poll results ('always' | 'after_voting' | 'after_ended').
*/
export const appSettingsSchema = z.object({
version: z.literal(SETTINGS_SCHEMA_VERSION),
Expand All @@ -31,6 +35,10 @@ export const appSettingsSchema = z.object({
electronicSignatureEnabled: z.boolean(),
signatureName: z.string().max(100),
requireSignatureOnCertificates: z.boolean(),
pollCreationEnabled: z.boolean(),
defaultPollDuration: z.number().int().min(1).max(30),
allowAnonymousVoting: z.boolean(),
pollResultsVisibility: z.enum(['always', 'after_voting', 'after_ended']),
});

/** Fully typed representation of all user settings. Inferred from `appSettingsSchema`. */
Expand Down Expand Up @@ -83,5 +91,9 @@ export function createDefaultSettings(): AppSettings {
electronicSignatureEnabled: false,
signatureName: '',
requireSignatureOnCertificates: false,
pollCreationEnabled: true,
defaultPollDuration: 7,
allowAnonymousVoting: false,
pollResultsVisibility: 'always',
};
}
105 changes: 105 additions & 0 deletions src/pages/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,111 @@ export default function SettingsPage() {
</div>
</section>

<section className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-6 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Poll Creation &amp; Voting</h2>

<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Configure default preferences for creating interactive polls in your classes, groups, or discussions.
</p>

<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
id="pollCreationEnabled"
type="checkbox"
className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={settings.pollCreationEnabled}
onChange={(e) =>
patchSettings({ pollCreationEnabled: e.target.checked })
}
/>
<span>
<span className="font-medium text-gray-900 dark:text-gray-50">
Enable interactive polls
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
When enabled, you can create and manage interactive polls in your study groups and courses.
</span>
</span>
</label>

<div
className={
settings.pollCreationEnabled ? 'space-y-4' : 'opacity-50 pointer-events-none space-y-4'
}
>
<div>
<label
htmlFor="defaultPollDuration"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Default poll duration
</label>
<select
id="defaultPollDuration"
value={settings.defaultPollDuration}
onChange={(e) => patchSettings({ defaultPollDuration: parseInt(e.target.value, 10) })}
disabled={!settings.pollCreationEnabled}
className="block w-full max-w-xs rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-gray-900 dark:text-gray-100 text-sm shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<option value={1}>1 Day</option>
<option value={3}>3 Days</option>
<option value={7}>7 Days (Recommended)</option>
<option value={14}>14 Days</option>
<option value={30}>30 Days</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Default active time limit for your newly created polls.
</p>
</div>

<label className="flex items-start gap-3 cursor-pointer">
<input
id="allowAnonymousVoting"
type="checkbox"
className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={settings.allowAnonymousVoting}
disabled={!settings.pollCreationEnabled}
onChange={(e) =>
patchSettings({ allowAnonymousVoting: e.target.checked })
}
/>
<span>
<span className="font-medium text-gray-900 dark:text-gray-50">
Allow anonymous voting by default
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
Voters can choose to keep their identities private from other participants.
</span>
</span>
</label>

<div>
<label
htmlFor="pollResultsVisibility"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Default poll results visibility
</label>
<select
id="pollResultsVisibility"
value={settings.pollResultsVisibility}
onChange={(e) => patchSettings({ pollResultsVisibility: e.target.value as any })}
disabled={!settings.pollCreationEnabled}
className="block w-full max-w-xs rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-gray-900 dark:text-gray-100 text-sm shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<option value="always">Always visible</option>
<option value="after_voting">Only after voting</option>
<option value="after_ended">Only after the poll has ended</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Determine who can see the current voting distribution.
</p>
</div>
</div>
</div>
</section>

<section className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-6 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
Export / import file
Expand Down
Loading