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'}"
|
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
|
||||||
|
|
||||||
ELIGIBLE_MIN_SESSIONS = 3
|
ELIGIBLE_MIN_SESSIONS = 3
|
||||||
APP_VERSION = "2.5.5"
|
APP_VERSION = "2.5.6"
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(env_path: Path) -> None:
|
def load_local_env(env_path: Path) -> None:
|
||||||
|
|||||||
@@ -302,6 +302,16 @@ def add_league_member(
|
|||||||
role: str,
|
role: str,
|
||||||
invited_by_user_id: str,
|
invited_by_user_id: str,
|
||||||
) -> LeagueMembership:
|
) -> 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(
|
membership = LeagueMembership(
|
||||||
league_id=league_id,
|
league_id=league_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -322,3 +332,19 @@ def remove_league_member(league_id: str, user_id: str) -> None:
|
|||||||
).one_or_none()
|
).one_or_none()
|
||||||
if membership and membership.role != "owner":
|
if membership and membership.role != "owner":
|
||||||
membership.disabled_at = utc_now()
|
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))
|
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")
|
@leagues_bp.post("/l/<league_ref>/archive")
|
||||||
@login_required
|
@login_required
|
||||||
def archive_league(league_ref: str):
|
def archive_league(league_ref: str):
|
||||||
|
|||||||
@@ -3693,6 +3693,83 @@ select.control { cursor: pointer; }
|
|||||||
.member-row:last-child { border-bottom: none; }
|
.member-row:last-child { border-bottom: none; }
|
||||||
.member-row > div { min-width: 0; }
|
.member-row > div { min-width: 0; }
|
||||||
.member-row strong { font: 600 14px/1.4 var(--font-ui); color: var(--text-1); }
|
.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 { position: relative; flex-shrink: 0; }
|
||||||
.card-menu__trigger {
|
.card-menu__trigger {
|
||||||
|
|||||||
+113
-24
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel access-panel">
|
||||||
<div class="panel__head">
|
<div class="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p class="kicker" style="margin:0;">Access</p>
|
<p class="kicker" style="margin:0;">Access</p>
|
||||||
@@ -55,40 +55,58 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="panel__tag">{{ members|length }} member{{ 's' if members|length != 1 else '' }}</span>
|
<span class="panel__tag">{{ members|length }} member{{ 's' if members|length != 1 else '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel__body" style="padding-bottom:0;">
|
<div class="access-panel__grid">
|
||||||
{% for membership, member_user in members %}
|
<div class="access-panel__members" aria-label="League members">
|
||||||
<div class="member-row">
|
{% for membership, member_user in members %}
|
||||||
<div>
|
<div class="member-row">
|
||||||
<strong>{{ member_user.email }}</strong>
|
<div class="member-row__identity">
|
||||||
<span class="kicker" style="margin-left:8px;text-transform:capitalize;">{{ membership.role }}</span>
|
<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>
|
</div>
|
||||||
{% if membership.role != 'owner' %}
|
{% endfor %}
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
<form class="access-panel__invite form-card" id="invite-member-form" method="post" action="{{ url_for('leagues.invite_member', league_ref=league.url_ref) }}">
|
||||||
</div>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<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);">
|
<div>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<p class="kicker" style="margin:0 0 4px;">Invite by email</p>
|
||||||
<p class="kicker" style="margin-bottom:10px;">Invite by email</p>
|
<h3 class="access-panel__invite-title">Add a member</h3>
|
||||||
<div class="session-create-row" style="align-items:flex-end;gap:10px;">
|
</div>
|
||||||
<label style="flex:1 1 200px;">
|
<label>
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<input type="email" name="email" placeholder="friend@example.com" required>
|
<input type="email" name="email" placeholder="friend@example.com" required>
|
||||||
</label>
|
</label>
|
||||||
<label style="flex:0 0 130px;">
|
<label>
|
||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
<select name="role">
|
<select name="role">
|
||||||
<option value="manager">Manager</option>
|
<option value="manager">Manager</option>
|
||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn--primary btn--sm" type="submit" style="flex:0 0 auto;margin-bottom:1px;">Send invite</button>
|
<button class="btn btn--primary btn--sm" type="submit">Send invite</button>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -111,6 +129,41 @@
|
|||||||
|
|
||||||
</div>
|
</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-backdrop" id="modal-archive" hidden>
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-card__head">
|
<div class="modal-card__head">
|
||||||
@@ -178,6 +231,42 @@
|
|||||||
inp.addEventListener('input', () => checkConfirmInput(inp));
|
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');
|
const inviteForm = document.getElementById('invite-member-form');
|
||||||
if (inviteForm) {
|
if (inviteForm) {
|
||||||
inviteForm.addEventListener('submit', () => {
|
inviteForm.addEventListener('submit', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user