fix re-invites + promote/demote members

This commit is contained in:
SowinskiBraeden committed 2026-06-27 10:33:54 -07:00
1 parent 3026204a99
commit 00d59ac8ae
5 files changed
+247 -25

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
ELIGIBLE_MIN_SESSIONS = 3
APP_VERSION = "2.5.5"
APP_VERSION = "2.5.6"
def load_local_env(env_path: Path) -> None:
+26
View File
@@ -302,6 +302,16 @@ def add_league_member(
role: str,
invited_by_user_id: str,
) -> LeagueMembership:
existing = LeagueMembership.query.filter_by(
league_id=league_id,
user_id=user_id,
).one_or_none()
if existing is not None:
existing.role = role
existing.invited_by_user_id = invited_by_user_id or None
existing.disabled_at = None
return existing
membership = LeagueMembership(
league_id=league_id,
user_id=user_id,
@@ -322,3 +332,19 @@ def remove_league_member(league_id: str, user_id: str) -> None:
).one_or_none()
if membership and membership.role != "owner":
membership.disabled_at = utc_now()
def set_league_member_role(league_id: str, user_id: str, role: str) -> LeagueMembership | None:
if role not in {"manager", "viewer"}:
raise ValueError(f"Unsupported member role: {role}")
membership = LeagueMembership.query.filter_by(
league_id=league_id,
user_id=user_id,
disabled_at=None,
).one_or_none()
if membership is None or membership.role == "owner":
return None
membership.role = role
return membership
+30
View File
@@ -1262,6 +1262,36 @@ def remove_member(league_ref: str, user_id: str):
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
@leagues_bp.post("/l/<league_ref>/settings/members/<user_id>/role")
@login_required
def update_member_role(league_ref: str, user_id: str):
if not db_ready():
flash("League database is not available.", "error")
return redirect(url_for("public.home"))
from league_repositories import set_league_member_role
league = require_league(league_ref, {"owner"})
role = request.form.get("role", "").strip()
if user_id == current_user_id():
flash("You cannot change your own league role.", "error")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
if role not in {"manager", "viewer"}:
flash("Invalid member role.", "error")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
membership = set_league_member_role(league.id, user_id, role)
if membership is None:
flash("That member cannot be updated.", "error")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
db.session.commit()
flash(f"Member changed to {role}.", "success")
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
@leagues_bp.post("/l/<league_ref>/archive")
@login_required
def archive_league(league_ref: str):
+77
View File
@@ -3693,6 +3693,83 @@ select.control { cursor: pointer; }
.member-row:last-child { border-bottom: none; }
.member-row > div { min-width: 0; }
.member-row strong { font: 600 14px/1.4 var(--font-ui); color: var(--text-1); }
.access-panel__grid {
display: grid;
grid-template-columns: minmax(320px, 0.82fr) minmax(320px, 420px);
min-height: 228px;
border-top: 1px solid var(--border);
}
.access-panel__members {
min-width: 0;
}
.access-panel__invite {
border-left: 1px solid var(--border);
padding: 18px 20px 20px;
gap: 14px;
}
.access-panel__invite-title {
margin: 0;
font: 700 16px/1.25 var(--font-ui);
color: var(--text-strong);
}
.access-panel__invite .btn {
justify-self: start;
}
.member-row {
align-items: flex-start;
padding: 14px 20px;
}
.member-row__identity {
display: grid;
gap: 6px;
}
.member-row__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.member-role {
justify-self: start;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font: 700 10px/1 var(--font-ui);
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted);
background: var(--surface-subtle);
}
.member-role--owner {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.member-role--manager {
color: var(--pos);
border-color: var(--pos-tint-bd);
background: var(--pos-tint);
}
.member-role--viewer {
color: var(--text-2);
}
@media (max-width: 760px) {
.access-panel__grid {
grid-template-columns: 1fr;
}
.access-panel__invite {
border-left: none;
border-top: 1px solid var(--border);
}
.member-row {
flex-direction: column;
align-items: stretch;
}
.member-row__actions {
justify-content: flex-start;
}
}
.card-menu { position: relative; flex-shrink: 0; }
.card-menu__trigger {
+113 -24
View File
@@ -47,7 +47,7 @@
</div>
</form>
<div class="panel">
<div class="panel access-panel">
<div class="panel__head">
<div>
<p class="kicker" style="margin:0;">Access</p>
@@ -55,40 +55,58 @@
</div>
<span class="panel__tag">{{ members|length }} member{{ 's' if members|length != 1 else '' }}</span>
</div>
<div class="panel__body" style="padding-bottom:0;">
{% for membership, member_user in members %}
<div class="member-row">
<div>
<strong>{{ member_user.email }}</strong>
<span class="kicker" style="margin-left:8px;text-transform:capitalize;">{{ membership.role }}</span>
<div class="access-panel__grid">
<div class="access-panel__members" aria-label="League members">
{% for membership, member_user in members %}
<div class="member-row">
<div class="member-row__identity">
<strong>{{ member_user.email }}</strong>
<span class="member-role member-role--{{ membership.role }}">{{ membership.role }}</span>
</div>
{% if membership.role != 'owner' and member_user.id != current_user_id %}
<div class="member-row__actions">
{% if membership.role == 'viewer' %}
<button class="btn btn--ghost btn--sm" type="button"
data-member-action="promote"
data-member-email="{{ member_user.email }}"
data-member-role="manager"
data-member-url="{{ url_for('leagues.update_member_role', league_ref=league.url_ref, user_id=member_user.id) }}">Promote</button>
{% elif membership.role == 'manager' %}
<button class="btn btn--ghost btn--sm" type="button"
data-member-action="demote"
data-member-email="{{ member_user.email }}"
data-member-role="viewer"
data-member-url="{{ url_for('leagues.update_member_role', league_ref=league.url_ref, user_id=member_user.id) }}">Demote</button>
{% endif %}
<button class="btn btn--ghost btn--sm" type="button"
data-member-action="remove"
data-member-email="{{ member_user.email }}"
data-member-url="{{ url_for('leagues.remove_member', league_ref=league.url_ref, user_id=member_user.id) }}">Remove</button>
</div>
{% endif %}
</div>
{% if membership.role != 'owner' %}
<form method="post" action="{{ url_for('leagues.remove_member', league_ref=league.url_ref, user_id=member_user.id) }}" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--ghost btn--sm" type="submit">Remove</button>
</form>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
<form class="form-card" id="invite-member-form" method="post" action="{{ url_for('leagues.invite_member', league_ref=league.url_ref) }}" style="padding:16px 20px 20px;border-top:1px solid var(--border);">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<p class="kicker" style="margin-bottom:10px;">Invite by email</p>
<div class="session-create-row" style="align-items:flex-end;gap:10px;">
<label style="flex:1 1 200px;">
<form class="access-panel__invite form-card" id="invite-member-form" method="post" action="{{ url_for('leagues.invite_member', league_ref=league.url_ref) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<p class="kicker" style="margin:0 0 4px;">Invite by email</p>
<h3 class="access-panel__invite-title">Add a member</h3>
</div>
<label>
<span>Email</span>
<input type="email" name="email" placeholder="friend@example.com" required>
</label>
<label style="flex:0 0 130px;">
<label>
<span>Role</span>
<select name="role">
<option value="manager">Manager</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button class="btn btn--primary btn--sm" type="submit" style="flex:0 0 auto;margin-bottom:1px;">Send invite</button>
</div>
</form>
<button class="btn btn--primary btn--sm" type="submit">Send invite</button>
</form>
</div>
</div>
<div class="panel">
@@ -111,6 +129,41 @@
</div>
<div class="modal-backdrop" id="modal-member-role" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-member-role-title">
<div class="modal-card__head">
<span class="kicker" style="margin:0;">Access</span>
<h2 class="panel__title" id="modal-member-role-title">Change member role?</h2>
</div>
<p class="modal-card__text" id="modal-member-role-text"></p>
<form id="member-role-form" method="post" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="role" id="member-role-input" value="">
<div class="modal-card__actions">
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
<button class="btn btn--primary btn--sm" id="member-role-confirm" type="submit">Confirm</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" id="modal-member-remove" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-member-remove-title">
<div class="modal-card__head">
<span class="kicker" style="margin:0;">Access</span>
<h2 class="panel__title" id="modal-member-remove-title">Remove member?</h2>
</div>
<p class="modal-card__text" id="modal-member-remove-text"></p>
<form id="member-remove-form" method="post" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-card__actions">
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
<button class="btn btn--danger btn--sm" type="submit">Remove member</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" id="modal-archive" hidden>
<div class="modal-card">
<div class="modal-card__head">
@@ -178,6 +231,42 @@
inp.addEventListener('input', () => checkConfirmInput(inp));
});
const roleModal = document.getElementById('modal-member-role');
const roleForm = document.getElementById('member-role-form');
const roleInput = document.getElementById('member-role-input');
const roleTitle = document.getElementById('modal-member-role-title');
const roleText = document.getElementById('modal-member-role-text');
const roleConfirm = document.getElementById('member-role-confirm');
const removeModal = document.getElementById('modal-member-remove');
const removeForm = document.getElementById('member-remove-form');
const removeTitle = document.getElementById('modal-member-remove-title');
const removeText = document.getElementById('modal-member-remove-text');
document.querySelectorAll('[data-member-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.memberAction;
const email = btn.dataset.memberEmail;
if (action === 'remove') {
removeForm.action = btn.dataset.memberUrl;
removeTitle.textContent = 'Remove ' + email + '?';
removeText.textContent = 'This member will lose access to ' + {{ league.name|tojson }} + '. Their league data and ledger history will stay intact.';
removeModal.removeAttribute('hidden');
removeModal.querySelector('[data-close-modal]')?.focus();
return;
}
const nextRole = btn.dataset.memberRole;
roleForm.action = btn.dataset.memberUrl;
roleInput.value = nextRole;
roleTitle.textContent = (action === 'promote' ? 'Promote ' : 'Demote ') + email + '?';
roleText.textContent = email + ' will become a ' + nextRole + ' for ' + {{ league.name|tojson }} + '.';
roleConfirm.textContent = action === 'promote' ? 'Promote member' : 'Demote member';
roleModal.removeAttribute('hidden');
roleModal.querySelector('[data-close-modal]')?.focus();
});
});
const inviteForm = document.getElementById('invite-member-form');
if (inviteForm) {
inviteForm.addEventListener('submit', () => {