fix re-invites + promote/demote members
This commit is contained in:
1 parent
3026204a99
commit
00d59ac8ae
5 files changed
+247
-25
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user