Add a Permissions menu with privilege keys: list, create, remove and use

Permissions > Use Privilege Key redeems a key (tokenuse). Permissions >
Privilege Keys opens a per-tab window listing the server's keys with
their group, channel, creation date and description; keys can be created,
removed, copied and reloaded. The create dialog takes a server group, or a
channel group in a chosen channel, plus a description, offers only groups
we have the power to add members to, and shows each new key for copying.

tokenlist and tokenadd answer with notifytokenlist / notifytokenadd ahead
of the command's completion, so core collects them the way it does the
ban list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:00:27 +00:00
parent 2c1b37ee30
commit 4911542d39
12 changed files with 806 additions and 9 deletions

View File

@@ -662,6 +662,8 @@ final class ConnectionEventHandler implements TS3Listener {
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e); if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e); else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e); else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e);
else if ("notifytokenlist".equals(e.getCommand())) conn.privilegeKeys.onListEntry(e);
else if ("notifytokenadd".equals(e.getCommand())) conn.privilegeKeys.onAdded(e);
} }
// ---- helpers ---- // ---- helpers ----

View File

@@ -0,0 +1,36 @@
package com.ts3client.net;
import java.util.Map;
/**
* A privilege key (token): a one-time code that puts whoever redeems it into a server
* group, or into a channel group in one channel.
*
* @param groupId the server or channel group it grants
* @param channelId the channel a channel group key applies in; 0 for a server group key
*/
public record PrivilegeKey(String token, boolean channelGroup, int groupId, int channelId,
long createdEpochSeconds, String description) {
/** Reads a key off a {@code notifytokenlist} event's fields. */
static PrivilegeKey from(Map<String, String> fields) {
return new PrivilegeKey(orEmpty(fields.get("token")),
parseLong(fields.get("token_type")) == 1,
(int) parseLong(fields.get("token_id1")),
(int) parseLong(fields.get("token_id2")),
parseLong(fields.get("token_created")),
orEmpty(fields.get("token_description")));
}
private static String orEmpty(String s) {
return s == null || s.equals("null") ? "" : s;
}
private static long parseLong(String s) {
try {
return s == null ? 0 : Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -0,0 +1,90 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.command.CommandException;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* The server's privilege keys: listing, creating, deleting and redeeming them.
*
* <p>Split out of {@link TeamspeakConnection} like {@link BanAdmin}, and answered the same
* way: {@code tokenlist} and {@code tokenadd} reply with notifications ahead of the
* command's own completion. Every call here blocks and must run off the UI thread; the
* connection's public wrappers do that.
*/
final class PrivilegeKeyAdmin {
/** What {@code tokenlist} answers when there are no keys at all. */
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
private final TeamspeakConnection conn;
/** The {@code tokenlist} reply in progress; one request may be in flight at a time. */
private volatile List<PrivilegeKey> listing;
/** The key the {@code tokenadd} in flight was answered with. */
private volatile String created;
PrivilegeKeyAdmin(TeamspeakConnection conn) {
this.conn = conn;
}
List<PrivilegeKey> list() throws Exception {
List<PrivilegeKey> keys = Collections.synchronizedList(new ArrayList<>());
listing = keys;
try {
conn.socket().executeCommand(new SingleCommand("tokenlist", ProtocolRole.CLIENT)).complete();
conn.awaitEventsProcessed();
} catch (CommandException e) {
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
} finally {
listing = null;
}
return new ArrayList<>(keys);
}
void onListEntry(UnknownTeamspeakEvent e) {
List<PrivilegeKey> keys = listing;
if (keys != null) keys.add(PrivilegeKey.from(e.getMap()));
}
/**
* Creates a key.
*
* @param channelId the channel a channel group key applies in; ignored for a server group
* @return the new key
*/
synchronized String add(boolean channelGroup, int groupId, int channelId, String description) throws Exception {
SingleCommand cmd = new SingleCommand("tokenadd", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("tokentype", channelGroup ? "1" : "0"));
cmd.add(new CommandSingleParameter("tokenid1", Integer.toString(groupId)));
cmd.add(new CommandSingleParameter("tokenid2", channelGroup ? Integer.toString(channelId) : "0"));
cmd.add(new CommandSingleParameter("tokendescription", description));
created = null;
conn.socket().executeCommand(cmd).complete();
conn.awaitEventsProcessed();
String token = created;
if (token == null || token.isEmpty()) throw new IllegalStateException("The server did not return the key");
return token;
}
void onAdded(UnknownTeamspeakEvent e) {
created = e.get("token");
}
void delete(String token) throws Exception {
conn.socket().executeCommand(new SingleCommand("tokendelete", ProtocolRole.CLIENT,
new CommandSingleParameter("token", token))).complete();
}
/** Redeems a key; the server announces the resulting group change itself. */
void use(String token) throws Exception {
conn.socket().executeCommand(new SingleCommand("tokenuse", ProtocolRole.CLIENT,
new CommandSingleParameter("token", token))).complete();
}
}

View File

@@ -243,6 +243,15 @@ public final class ServerModel {
return out; return out;
} }
/** The regular server or channel groups we have the power to put someone in, so may make a privilege key for. */
public synchronized List<Group> privilegeKeyGroups(boolean channelGroups) {
List<Group> out = new ArrayList<>();
for (Group g : channelGroups ? allChannelGroups() : allServerGroups()) {
if (canToggleGroup(g, false)) out.add(g);
}
return out;
}
private static boolean hasPower(int needed, int own) { private static boolean hasPower(int needed, int own) {
if (own == -1) return true; if (own == -1) return true;
if (needed == -1) return false; if (needed == -1) return false;
@@ -397,6 +406,20 @@ public final class ServerModel {
return children; return children;
} }
/** Every channel, depth first in the order the tree shows them. */
public synchronized List<ChannelNode> channelsInTreeOrder() {
List<ChannelNode> out = new ArrayList<>();
addInTreeOrder(0, out);
return out;
}
private void addInTreeOrder(int parentId, List<ChannelNode> out) {
for (ChannelNode c : childrenOf(parentId)) {
out.add(c);
addInTreeOrder(c.id, out);
}
}
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */ /** The channel of that exact name directly below {@code parentId}, or {@code null}. */
public synchronized ChannelNode findChannelByName(int parentId, String name) { public synchronized ChannelNode findChannelByName(int parentId, String name) {
for (ChannelNode c : channels.values()) { for (ChannelNode c : channels.values()) {

View File

@@ -95,8 +95,9 @@ public final class TeamspeakConnection implements TS3Listener {
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this); final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
/** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */ /** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
final ChannelAdmin channels = new ChannelAdmin(this); final ChannelAdmin channels = new ChannelAdmin(this);
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */ /** The server's ban list and privilege keys; also package-private for {@link ConnectionEventHandler}. */
final BanAdmin bans = new BanAdmin(this); final BanAdmin bans = new BanAdmin(this);
final PrivilegeKeyAdmin privilegeKeys = new PrivilegeKeyAdmin(this);
private final AvatarAdmin avatarAdmin = new AvatarAdmin(this); private final AvatarAdmin avatarAdmin = new AvatarAdmin(this);
/* /*
@@ -954,6 +955,43 @@ public final class TeamspeakConnection implements TS3Listener {
}); });
} }
// ---- privilege keys ----
/** Reads the server's privilege keys, delivering them (or a failure message) off the UI thread. */
public void requestPrivilegeKeys(BiConsumer<List<PrivilegeKey>, String> callback) {
run(callback, privilegeKeys::list);
}
/**
* Creates a privilege key for a server group, or for a channel group in {@code channelId}.
*
* @param callback given the new key, or the failure message
*/
public void addPrivilegeKey(boolean channelGroup, int groupId, int channelId, String description,
BiConsumer<String, String> callback) {
run(callback, () -> privilegeKeys.add(channelGroup, groupId, channelId, description));
}
/**
* Deletes privilege keys one by one, stopping at the first the server refuses.
*
* @param callback given {@code null} on success, or the failure message
*/
public void deletePrivilegeKeys(Collection<String> tokens, Consumer<String> callback) {
run((ignored, error) -> callback.accept(error), () -> {
for (String token : tokens) privilegeKeys.delete(token);
return null;
});
}
/** @param callback given {@code null} on success, or the failure message */
public void usePrivilegeKey(String token, Consumer<String> callback) {
run((ignored, error) -> callback.accept(error), () -> {
privilegeKeys.use(token);
return null;
});
}
// ---- bans ---- // ---- bans ----
/** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */ /** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */

View File

@@ -387,6 +387,11 @@ public final class ServerSession {
return permits("b_client_ban_list"); return permits("b_client_ban_list");
} }
/** Whether the server grants us {@code b_virtualserver_token_list}; offered while unknown. */
public boolean canListPrivilegeKeys() {
return permits("b_virtualserver_token_list");
}
/** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */ /** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */
public boolean canSetAvatar() { public boolean canSetAvatar() {
return permits("i_client_max_avatar_filesize"); return permits("i_client_max_avatar_filesize");

View File

@@ -283,6 +283,21 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (selected != null) selected.showBanList(banReasonPresets()); if (selected != null) selected.showBanList(banReasonPresets());
} }
@Override
public boolean canListPrivilegeKeys() {
return selected != null && selected.session().canListPrivilegeKeys();
}
@Override
public void onShowPrivilegeKeys() {
if (selected != null) selected.showPrivilegeKeys();
}
@Override
public void onUsePrivilegeKey() {
if (selected != null) selected.usePrivilegeKey();
}
@Override @Override
public void onShowSettings() { public void onShowSettings() {
showSettings(); showSettings();

View File

@@ -18,7 +18,7 @@ import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
/** /**
* The main window's menu bar: Connections, Bookmarks, Self, Tools and Help. * The main window's menu bar: Connections, Bookmarks, Self, Permissions, Tools and Help.
* Talks to {@link MainFrame} only through {@link Listener}; the away/commander * Talks to {@link MainFrame} only through {@link Listener}; the away/commander
* item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar} * item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar}
* is kept in sync. * is kept in sync.
@@ -66,6 +66,13 @@ final class MainMenuBar extends JMenuBar {
void onShowBanList(); void onShowBanList();
/** Whether the current server lets us list its privilege keys; asked as the Permissions menu opens. */
boolean canListPrivilegeKeys();
void onShowPrivilegeKeys();
void onUsePrivilegeKey();
void onShowSettings(); void onShowSettings();
void onShowAbout(); void onShowAbout();
@@ -88,6 +95,8 @@ final class MainMenuBar extends JMenuBar {
private JMenuItem avatarItem; private JMenuItem avatarItem;
private JMenuItem deleteAvatarItem; private JMenuItem deleteAvatarItem;
private JMenuItem banListItem; private JMenuItem banListItem;
private JMenuItem privilegeKeysItem;
private JMenuItem usePrivilegeKeyItem;
MainMenuBar(Bookmarks bookmarks, Listener listener) { MainMenuBar(Bookmarks bookmarks, Listener listener) {
this.bookmarks = bookmarks; this.bookmarks = bookmarks;
@@ -171,6 +180,28 @@ final class MainMenuBar extends JMenuBar {
} }
}); });
JMenu permissions = new JMenu("Permissions");
privilegeKeysItem = new JMenuItem("Privilege Keys", Icons.of("TOKEN"));
privilegeKeysItem.addActionListener(e -> listener.onShowPrivilegeKeys());
usePrivilegeKeyItem = new JMenuItem("Use Privilege Key", Icons.of("TOKEN_USE"));
usePrivilegeKeyItem.addActionListener(e -> listener.onUsePrivilegeKey());
permissions.add(privilegeKeysItem);
permissions.add(usePrivilegeKeyItem);
permissions.addMenuListener(new MenuListener() {
@Override
public void menuSelected(MenuEvent e) {
privilegeKeysItem.setVisible(!privilegeKeysItem.isEnabled() || listener.canListPrivilegeKeys());
}
@Override
public void menuDeselected(MenuEvent e) {
}
@Override
public void menuCanceled(MenuEvent e) {
}
});
JMenu tools = new JMenu("Tools"); JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> listener.onShowIdentities()); identitiesItem.addActionListener(e -> listener.onShowIdentities());
@@ -214,6 +245,7 @@ final class MainMenuBar extends JMenuBar {
add(connections); add(connections);
add(bookmarksMenu); add(bookmarksMenu);
add(self); add(self);
add(permissions);
add(tools); add(tools);
add(help); add(help);
} }
@@ -226,6 +258,8 @@ final class MainMenuBar extends JMenuBar {
avatarItem.setEnabled(connected); avatarItem.setEnabled(connected);
deleteAvatarItem.setEnabled(connected); deleteAvatarItem.setEnabled(connected);
banListItem.setEnabled(connected); banListItem.setEnabled(connected);
privilegeKeysItem.setEnabled(connected);
usePrivilegeKeyItem.setEnabled(connected);
awayItem.setSelected(away); awayItem.setSelected(away);
commanderItem.setSelected(commander); commanderItem.setSelected(commander);
} }

View File

@@ -0,0 +1,168 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.Box;
import javax.swing.ButtonGroup;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.Toolkit;
import java.awt.Window;
import java.awt.datatransfer.StringSelection;
/**
* Creates privilege keys, as the official client's "Create Privilege Key" dialog: a server
* group, or a channel group in a chosen channel, and an optional description. Each key
* created is shown for copying, and the dialog stays open for the next one.
*/
final class PrivilegeKeyCreateDialog extends JDialog {
private final TeamspeakConnection conn;
private final Runnable onCreated;
private final JRadioButton serverGroup = new JRadioButton("Server Group", true);
private final JRadioButton channelGroup = new JRadioButton("Channel Group");
private final JComboBox<Group> groupBox = new JComboBox<>();
private final JComboBox<ChannelNode> channelBox = new JComboBox<>();
private final JTextField descriptionField = new JTextField(28);
private final JTextField keyField = new JTextField(28);
private final JButton copy = new JButton("Copy", Icons.of("COPY"));
private final JButton create = new JButton("Create");
/** @param onCreated runs on the UI thread after each key is created */
PrivilegeKeyCreateDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, Runnable onCreated) {
super(owner, "Create Privilege Key", ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.onCreated = onCreated;
ButtonGroup type = new ButtonGroup();
type.add(serverGroup);
type.add(channelGroup);
serverGroup.addActionListener(e -> typeChanged());
channelGroup.addActionListener(e -> typeChanged());
JPanel typeRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
typeRow.add(serverGroup);
typeRow.add(Box.createHorizontalStrut(UIScale.scale(12)));
typeRow.add(channelGroup);
groupBox.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, value, index, selected, focused);
if (value instanceof Group g) {
setText(g.name);
setIcon(groupIcons.iconOf(g));
}
return this;
}
});
groupBox.addActionListener(e -> updateCreate());
ServerModel model = conn.getModel();
channelBox.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, value, index, selected, focused);
if (value instanceof ChannelNode c) setText(model.channelPath(c.id));
return this;
}
});
for (ChannelNode c : model.channelsInTreeOrder()) channelBox.addItem(c);
channelBox.setToolTipText("When creating a privilege key for a channel group, specify the channel here");
descriptionField.setToolTipText("Optional description for this privilege key");
descriptionField.addActionListener(e -> create());
keyField.setEditable(false);
copy.setEnabled(false);
copy.addActionListener(e -> Toolkit.getDefaultToolkit().getSystemClipboard()
.setContents(new StringSelection(keyField.getText()), null));
JPanel keyRow = new JPanel(new BorderLayout(4, 0));
keyRow.add(keyField, BorderLayout.CENTER);
keyRow.add(copy, BorderLayout.EAST);
FormPanel form = new FormPanel();
GridBagConstraints c = FormPanel.gbc();
FormPanel.addRow(form, c, 0, new JLabel("Group Type:"), typeRow);
FormPanel.addRow(form, c, 1, new JLabel("Group:"), groupBox);
FormPanel.addRow(form, c, 2, new JLabel("Channel:"), channelBox);
FormPanel.addRow(form, c, 3, new JLabel("Description:"), descriptionField);
FormPanel.addRow(form, c, 4, new JLabel("Privilege Key:"), keyRow);
create.addActionListener(e -> create());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT));
buttons.setBorder(new ScaledEmptyBorder(0, 8, 4, 8));
buttons.add(create);
buttons.add(close);
getRootPane().setDefaultButton(create);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
typeChanged();
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack();
setResizable(false);
setLocationRelativeTo(owner);
}
private boolean forChannelGroup() {
return channelGroup.isSelected();
}
private void typeChanged() {
groupBox.removeAllItems();
for (Group g : conn.getModel().privilegeKeyGroups(forChannelGroup())) groupBox.addItem(g);
channelBox.setEnabled(forChannelGroup());
updateCreate();
}
private void updateCreate() {
create.setEnabled(groupBox.getSelectedItem() != null
&& (!forChannelGroup() || channelBox.getSelectedItem() != null));
}
private void create() {
if (!create.isEnabled()) return;
Group group = (Group) groupBox.getSelectedItem();
ChannelNode channel = (ChannelNode) channelBox.getSelectedItem();
create.setEnabled(false);
conn.addPrivilegeKey(forChannelGroup(), group.id, channel == null ? 0 : channel.id,
descriptionField.getText().trim(), (key, error) -> SwingUtilities.invokeLater(() -> {
if (!isDisplayable()) return;
updateCreate();
if (error != null) {
JOptionPane.showMessageDialog(this, "Failed to add Privilege Key: " + error,
getTitle(), JOptionPane.ERROR_MESSAGE);
return;
}
keyField.setText(key);
keyField.selectAll();
keyField.requestFocusInWindow();
copy.setEnabled(true);
onCreated.run();
}));
}
}

View File

@@ -0,0 +1,355 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.net.Group;
import com.ts3client.net.PrivilegeKey;
import com.ts3client.net.ServerModel;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.AbstractAction;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.TableRowSorter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* The server's privilege keys, as the official client's Permissions &gt; Privilege Keys
* window: Create and Remove above the list, Reload below it.
*
* <p>One per server tab and modeless, like the ban list; the tab closes it when the
* connection goes away.
*/
final class PrivilegeKeysDialog extends JDialog {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private enum Column {
KEY("Privilege Key", 320),
TYPE("Type", 110),
GROUP("Group", 140),
CHANNEL("Channel", 140),
CREATED("Created", 150),
DESCRIPTION("Description", 180);
final String title;
final int width;
Column(String title, int width) {
this.title = title;
this.width = width;
}
}
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final KeyTableModel tableModel = new KeyTableModel();
private final JTable table = new JTable(tableModel);
private final JButton createButton = new JButton("Create", Icons.of("ADD"));
private final JButton removeButton = new JButton("Remove", Icons.of("DELETE"));
private final JLabel status = new JLabel(" ");
private boolean loading;
PrivilegeKeysDialog(Frame owner, TeamspeakConnection conn, GroupIcons groupIcons) {
super(owner, "Privilege Keys", false);
this.conn = conn;
this.groupIcons = groupIcons;
JPanel content = new JPanel(new BorderLayout(0, UIScale.scale(6)));
content.setBackground(Theme.windowBg());
content.setBorder(new ScaledEmptyBorder(10, 12, 10, 12));
content.add(buildToolbar(), BorderLayout.NORTH);
content.add(buildTable(), BorderLayout.CENTER);
content.add(buildBottom(), BorderLayout.SOUTH);
setContentPane(content);
bindKeys();
serverNameChanged();
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setPreferredSize(UIScale.scale(new Dimension(1040, 400)));
pack();
setLocationRelativeTo(owner);
reload();
}
// ---- construction ----
private JPanel buildToolbar() {
createButton.setToolTipText("<html>Create new privilege key<br>(Ctrl+N)");
removeButton.setToolTipText("<html>Remove selected privilege keys<br>(Delete)");
createButton.addActionListener(e -> createKey());
removeButton.addActionListener(e -> removeKeys());
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setOpaque(false);
bar.add(createButton);
bar.add(Box.createHorizontalStrut(UIScale.scale(6)));
bar.add(removeButton);
bar.add(Box.createHorizontalGlue());
return bar;
}
private JScrollPane buildTable() {
table.setFont(Theme.uiFont());
table.setRowHeight(UIScale.scale(20));
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
table.setFillsViewportHeight(true);
table.setRowSorter(new TableRowSorter<>(tableModel));
table.getSelectionModel().addListSelectionListener(e -> updateButtons());
table.setComponentPopupMenu(tablePopup());
table.getTableHeader().setReorderingAllowed(true);
KeyCellRenderer renderer = new KeyCellRenderer();
for (Column c : Column.values()) {
table.getColumnModel().getColumn(c.ordinal()).setPreferredWidth(UIScale.scale(c.width));
table.getColumnModel().getColumn(c.ordinal()).setCellRenderer(renderer);
}
JScrollPane scroll = new JScrollPane(table);
scroll.getViewport().setBackground(Theme.treeBg());
return scroll;
}
private JPanel buildBottom() {
JButton reload = new JButton("Reload", Icons.of("REFRESH"));
reload.setToolTipText("<html>Reload privilege keys<br>(F5)");
reload.addActionListener(e -> reload());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
JPanel row = new JPanel(new BorderLayout());
row.setOpaque(false);
row.add(reload, BorderLayout.WEST);
row.add(close, BorderLayout.EAST);
JPanel bottom = new JPanel(new BorderLayout(0, UIScale.scale(8)));
bottom.setOpaque(false);
bottom.add(status, BorderLayout.NORTH);
bottom.add(row, BorderLayout.SOUTH);
status.setForeground(Theme.chatEvent());
return bottom;
}
private JPopupMenu tablePopup() {
JPopupMenu menu = new JPopupMenu();
menu.add(item("Create", "ADD", this::createKey));
menu.add(item("Remove", "DELETE", this::removeKeys));
menu.add(item("Copy", "COPY", this::copyKeys));
menu.addSeparator();
menu.add(item("Reload", "REFRESH", this::reload));
return menu;
}
private static JMenuItem item(String text, String icon, Runnable action) {
JMenuItem item = new JMenuItem(text, Icons.of(icon));
item.addActionListener(e -> action.run());
return item;
}
private void bindKeys() {
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control N"), this::createKey);
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("F5"), this::reload);
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control W"), this::dispose);
bind(table, JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke("control C"), this::copyKeys);
bind(table, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0),
this::removeKeys);
Dialogs.closeOnEscape(this);
}
private static void bind(JComponent target, int condition, KeyStroke key, Runnable action) {
String name = "ts3-privilegekeys-" + key;
target.getInputMap(condition).put(key, name);
target.getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
action.run();
}
});
}
// ---- data ----
/** Follows the server's name, as the ban list's title does. */
void serverNameChanged() {
String name = conn.getModel().getServerName();
setTitle(name == null || name.isBlank() ? "Privilege Keys" : "Privilege Keys - " + name);
}
void connectionLost() {
dispose();
}
private void reload() {
if (loading) return;
loading = true;
status.setText("Loading…");
conn.requestPrivilegeKeys((keys, error) -> SwingUtilities.invokeLater(() -> {
loading = false;
if (!isDisplayable()) return;
if (error != null) {
tableModel.setKeys(List.of());
status.setText("Failed to request Privilege Key list: " + error);
} else {
tableModel.setKeys(keys);
status.setText(keys.size() == 1 ? "1 privilege key" : keys.size() + " privilege keys");
}
updateButtons();
}));
}
private List<PrivilegeKey> selectedKeys() {
List<PrivilegeKey> selected = new ArrayList<>();
for (int row : table.getSelectedRows()) selected.add(tableModel.get(table.convertRowIndexToModel(row)));
return selected;
}
private void updateButtons() {
ServerModel model = conn.getModel();
createButton.setEnabled(model.permits("b_virtualserver_token_add"));
removeButton.setEnabled(!selectedKeys().isEmpty() && model.permits("b_virtualserver_token_delete"));
}
// ---- actions ----
private void createKey() {
if (!createButton.isEnabled()) return;
new PrivilegeKeyCreateDialog(this, conn, groupIcons, this::reload).setVisible(true);
}
private void removeKeys() {
if (!removeButton.isEnabled()) return;
int answer = JOptionPane.showConfirmDialog(this, "Do you really want to delete the selected privilege key(s)?",
"Delete Privilege Keys", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if (answer != JOptionPane.YES_OPTION) return;
List<String> tokens = selectedKeys().stream().map(PrivilegeKey::token).collect(Collectors.toList());
conn.deletePrivilegeKeys(tokens, error -> SwingUtilities.invokeLater(() -> {
if (!isDisplayable()) return;
if (error != null) {
JOptionPane.showMessageDialog(this, "Failed to delete Privilege Key: " + error, getTitle(),
JOptionPane.ERROR_MESSAGE);
}
reload();
}));
}
private void copyKeys() {
List<PrivilegeKey> keys = selectedKeys();
if (keys.isEmpty()) return;
String text = keys.stream().map(PrivilegeKey::token).collect(Collectors.joining("\n"));
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null);
}
// ---- table ----
private Group groupOf(PrivilegeKey key) {
ServerModel model = conn.getModel();
return key.channelGroup() ? model.channelGroup(key.groupId()) : model.serverGroup(key.groupId());
}
private final class KeyTableModel extends AbstractTableModel {
private List<PrivilegeKey> keys = List.of();
void setKeys(List<PrivilegeKey> keys) {
this.keys = new ArrayList<>(keys);
fireTableDataChanged();
}
PrivilegeKey get(int row) {
return keys.get(row);
}
@Override
public int getRowCount() {
return keys.size();
}
@Override
public int getColumnCount() {
return Column.values().length;
}
@Override
public String getColumnName(int column) {
return Column.values()[column].title;
}
@Override
public Class<?> getColumnClass(int column) {
return Column.values()[column] == Column.CREATED ? Long.class : String.class;
}
@Override
public Object getValueAt(int row, int column) {
PrivilegeKey key = keys.get(row);
if (Column.values()[column] == Column.CREATED) return key.createdEpochSeconds();
return text(key, Column.values()[column]);
}
String text(PrivilegeKey key, Column column) {
switch (column) {
case KEY:
return key.token();
case TYPE:
return key.channelGroup() ? "Channel Group" : "Server Group";
case GROUP:
Group group = groupOf(key);
return group != null ? group.name : Integer.toString(key.groupId());
case CHANNEL:
if (!key.channelGroup()) return "";
String path = conn.getModel().channelPath(key.channelId());
return path.isEmpty() ? Integer.toString(key.channelId()) : path;
case CREATED:
return key.createdEpochSeconds() <= 0 ? "" : DATE.format(
Instant.ofEpochSecond(key.createdEpochSeconds()).atZone(ZoneId.systemDefault()));
case DESCRIPTION:
return key.description();
default:
return "";
}
}
}
/** Shows each column's text, with the group's icon beside its name. */
private final class KeyCellRenderer extends DefaultTableCellRenderer {
@Override
public Component getTableCellRendererComponent(JTable t, Object value, boolean selected,
boolean focused, int row, int column) {
PrivilegeKey key = tableModel.get(t.convertRowIndexToModel(row));
Column c = Column.values()[t.convertColumnIndexToModel(column)];
super.getTableCellRendererComponent(t, tableModel.text(key, c), selected, focused, row, column);
setIcon(c == Column.GROUP ? groupIcons.iconOf(groupOf(key)) : null);
return this;
}
}
}

View File

@@ -16,7 +16,9 @@ import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import java.awt.Component; import java.awt.Component;
import java.awt.event.ComponentAdapter; import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent; import java.awt.event.ComponentEvent;
@@ -52,6 +54,7 @@ final class ServerTab {
/** The one open ban list window for this server, if any. */ /** The one open ban list window for this server, if any. */
private BanListDialog banList; private BanListDialog banList;
private PrivilegeKeysDialog privilegeKeys;
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio, ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) { SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) {
@@ -187,11 +190,39 @@ final class ServerTab {
banList.toFront(); banList.toFront();
} }
/** The server's name changed or the connection went; the ban list window follows. */ /** Asks for a privilege key and redeems it on this server. */
void banListServerChanged(boolean connected) { void usePrivilegeKey() {
if (banList == null) return; if (!session.isConnected()) return;
if (connected) banList.serverNameChanged(); String key = (String) JOptionPane.showInputDialog(host, "Privilege Key:", "Use Privilege Key",
else banList.connectionLost(); JOptionPane.PLAIN_MESSAGE, Icons.of("TOKEN_USE"), null, null);
if (key == null || key.isBlank()) return;
conn.usePrivilegeKey(key.trim(), error -> {
if (error == null) return;
SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(host, error,
"Error using Privilege Key", JOptionPane.ERROR_MESSAGE));
});
}
/** Opens the server's privilege keys, or brings the already open window forward. */
void showPrivilegeKeys() {
if (!session.isConnected()) return;
if (privilegeKeys == null || !privilegeKeys.isDisplayable()) {
privilegeKeys = new PrivilegeKeysDialog(host, conn, groupIcons);
}
privilegeKeys.setVisible(true);
privilegeKeys.toFront();
}
/** The server's name changed or the connection went; the ban list and privilege keys windows follow. */
void serverWindowsChanged(boolean connected) {
if (banList != null) {
if (connected) banList.serverNameChanged();
else banList.connectionLost();
}
if (privilegeKeys != null) {
if (connected) privilegeKeys.serverNameChanged();
else privilegeKeys.connectionLost();
}
} }
/** The contact list changed: names, colours and per-client playback follow. */ /** The contact list changed: names, colours and per-client playback follow. */

View File

@@ -50,7 +50,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
@Override @Override
public void onSessionChanged() { public void onSessionChanged() {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
if (session.isConnected()) tab.banListServerChanged(true); if (session.isConnected()) tab.serverWindowsChanged(true);
host.tabUpdated(tab); host.tabUpdated(tab);
}); });
} }
@@ -97,7 +97,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
chatPanel.closeNoteTabs(); chatPanel.closeNoteTabs();
chatPanel.setServerName(null); chatPanel.setServerName(null);
chatPanel.setChannelName(null); chatPanel.setChannelName(null);
tab.banListServerChanged(false); tab.serverWindowsChanged(false);
host.tabDisconnected(tab); host.tabDisconnected(tab);
}); });
} }