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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"license": "MIT",
"packageManager": "pnpm@11.9.0",
"dependencies": {
"@dicebear/avataaars": "9.4.2",
"@dicebear/core": "^9.4.2",
"@monaco-editor/react": "^4.7.0",
"@supabase/supabase-js": "^2.110.8",
"@tanstack/react-query": "^5.101.4",
Expand Down
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions supabase/migrations/202607300005_student_profiles_feedback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
alter table public.profiles
add column if not exists avatar_config jsonb;

alter table public.profiles
drop constraint if exists profiles_avatar_config_object_check,
add constraint profiles_avatar_config_object_check
check (
avatar_config is null
or (
jsonb_typeof(avatar_config) = 'object'
and octet_length(avatar_config::text) <= 4096
)
);

alter table public.notifications
add column if not exists dismissed_at timestamptz;

create or replace function public.protect_profile_role()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if auth.uid() is not null and (
new.role is distinct from old.role
or new.email is distinct from old.email
or new.github_login is distinct from old.github_login
or new.github_id is distinct from old.github_id
or new.avatar_url is distinct from old.avatar_url
or new.created_at is distinct from old.created_at
) then
raise exception 'Profile identity fields cannot be changed by the user.';
end if;
return new;
end;
$$;

revoke update on public.notifications from authenticated;
grant update (read_at, dismissed_at) on public.notifications to authenticated;

do $$
begin
if exists (
select 1 from pg_publication where pubname = 'supabase_realtime'
) then
alter publication supabase_realtime add table public.profiles;
end if;
exception when duplicate_object then
null;
end;
$$;
44 changes: 43 additions & 1 deletion supabase/tests/rls.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
begin;

create extension if not exists pgtap with schema extensions;
select plan(18);
select plan(22);

insert into auth.users (
id, instance_id, aud, role, email, encrypted_password,
Expand Down Expand Up @@ -284,6 +284,48 @@ select is(
'students only read their own notifications'
);

select lives_ok(
$$
update public.profiles
set
display_name = 'Student Alias',
avatar_config = '{"top":"shortFlat"}'::jsonb
where id = '00000000-0000-0000-0000-000000000102'
$$,
'students can update their own display name and avatar configuration'
);

select throws_ok(
$$
update public.profiles
set github_login = 'forged-login'
where id = '00000000-0000-0000-0000-000000000102'
$$,
'P0001',
'Profile identity fields cannot be changed by the user.',
'students cannot change their GitHub identity'
);

select lives_ok(
$$
update public.notifications
set dismissed_at = now()
where title = 'Own feedback'
$$,
'students can dismiss their own feedback notification'
);

select throws_ok(
$$
update public.notifications
set title = 'Rewritten feedback'
where title = 'Own feedback'
$$,
'42501',
'permission denied for table notifications',
'students cannot rewrite mentor feedback'
);

select lives_ok(
$$
select public.record_student_activity(
Expand Down
48 changes: 48 additions & 0 deletions v2/e2e/student.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,54 @@ test("keeps independent code when changing language", async ({ page }) => {
.toBe(pythonDraft);
});

test("student edits their avatar and visible name", async ({ page }) => {
await page.locator(".profile-menu-trigger").click();
await page.getByRole("menuitem", { name: "Editar perfil" }).click();
await expect(page.getByRole("heading", { name: "Tu perfil" })).toBeVisible();

await page.getByLabel("Nombre visible").fill("Cami Rojas");
await page.getByRole("tab", { name: "Accesorios" }).click();
await page.getByRole("button", { name: "Redondos" }).click();
await page.getByRole("button", { name: "Argolla" }).click();
await page.getByRole("button", { name: "Guardar perfil" }).click();
await expect(page.getByText("Perfil actualizado.")).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);

await page.getByRole("link", { name: "Ranking" }).click();
await expect(
page.locator(".ranking-row").filter({ hasText: "Cami Rojas" }),
).toBeVisible();
});

test("feedback identifies its mission and can be dismissed", async ({ page }) => {
await page.getByRole("link", { name: "Feedback" }).click();
await expect(page.getByText("P1-01").first()).toBeVisible();
await expect(page.getByText("La once de Tomatin").first()).toBeVisible();
const before = await page.locator(".feedback-item").count();

await page
.getByRole("button", { name: /Eliminar feedback Comentario del mentor/ })
.click();
await expect(page.locator(".feedback-item")).toHaveCount(before - 1);
});

test("ranking places the winner above the other podium positions", async ({
page,
}) => {
await page.getByRole("link", { name: "Ranking" }).click();
const first = await page.locator(".podium-entry.place-1").boundingBox();
const second = await page.locator(".podium-entry.place-2").boundingBox();
const third = await page.locator(".podium-entry.place-3").boundingBox();
expect(first).not.toBeNull();
expect(second).not.toBeNull();
expect(third).not.toBeNull();
expect(first!.y).toBeLessThan(second!.y);
expect(first!.y).toBeLessThan(third!.y);
expect(first!.x).toBeGreaterThan(second!.x);
expect(first!.x).toBeLessThan(third!.x);
});

test("dashboard has no serious automated accessibility violations", async ({
page,
}) => {
Expand Down
2 changes: 2 additions & 0 deletions v2/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const MissionsPage = lazyPage(() => import("@/pages/MissionsPage"));
const MissionWorkspace = lazyPage(() => import("@/pages/MissionWorkspace"));
const RankingPage = lazyPage(() => import("@/pages/RankingPage"));
const FeedbackPage = lazyPage(() => import("@/pages/FeedbackPage"));
const ProfilePage = lazyPage(() => import("@/pages/ProfilePage"));
const MentorPage = lazyPage(() => import("@/pages/MentorPage"));
const AboutPage = lazyPage(() => import("@/pages/AboutPage"));

Expand Down Expand Up @@ -48,6 +49,7 @@ export function App() {
<Route path="mission/:slug" element={<MissionWorkspace />} />
<Route path="ranking" element={<RankingPage />} />
<Route path="feedback" element={<FeedbackPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="admin/*" element={<MentorPage />} />
<Route path="mentor" element={<Navigate to="/admin" replace />} />
<Route path="about" element={<AboutPage />} />
Expand Down
47 changes: 32 additions & 15 deletions v2/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MessageSquareText,
TriangleAlert,
Trophy,
UserRound,
Users,
X,
} from "lucide-react";
Expand All @@ -24,7 +25,7 @@ import {
useLocation,
useNavigate,
} from "react-router-dom";
import { initials } from "@/lib/format";
import { ProfileAvatar } from "@/components/ProfileAvatar";
import { useClassroom } from "@/state/classroom-context";

const studentNavigation = [
Expand Down Expand Up @@ -64,7 +65,10 @@ export function AppShell() {
const navigate = useNavigate();
const unread =
snapshot?.notifications.filter(
(entry) => entry.userId === viewProfile?.id && !entry.readAt,
(entry) =>
entry.userId === viewProfile?.id &&
!entry.readAt &&
!entry.dismissedAt,
).length ?? 0;
const isActorStaff =
profile?.role === "mentor" || profile?.role === "owner";
Expand Down Expand Up @@ -285,7 +289,9 @@ export function AppShell() {
aria-haspopup="menu"
onClick={() => setProfileOpen((current) => !current)}
>
<span className="avatar">{initials(profile?.displayName ?? "?")}</span>
{profile ? (
<ProfileAvatar profile={profile} size="medium" decorative />
) : null}
<span className="profile-copy">
<strong>{profile?.displayName}</strong>
<small>
Expand All @@ -299,18 +305,29 @@ export function AppShell() {
<ChevronDown aria-hidden="true" />
</button>
{profileOpen ? (
<button
className="profile-menu-action"
type="button"
role="menuitem"
onClick={() => {
setProfileOpen(false);
void logout();
}}
>
<LogOut aria-hidden="true" />
Cerrar sesión
</button>
<div className="profile-menu-popover" role="menu">
<NavLink
className="profile-menu-action"
role="menuitem"
to="/profile"
onClick={() => setProfileOpen(false)}
>
<UserRound aria-hidden="true" />
Editar perfil
</NavLink>
<button
className="profile-menu-action"
type="button"
role="menuitem"
onClick={() => {
setProfileOpen(false);
void logout();
}}
>
<LogOut aria-hidden="true" />
Cerrar sesión
</button>
</div>
) : null}
</div>
</div>
Expand Down
Loading